Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-10 16:15:30 +03:00
commit 2f62d482ca
635 changed files with 18802 additions and 9118 deletions

View file

@ -9,9 +9,15 @@ android {
namespace = "com.tangem.data.account"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
api(projects.core.utils)
// endregion
@ -20,18 +26,43 @@ dependencies {
api(projects.domain.models)
// endregion
// Project - Data
implementation(projects.core.datasource)
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Libs
implementation(projects.libs.crypto)
implementation(projects.libs.blockchainSdk)
// endregion
// region Tangem dependencies
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
// endregion
// region DI
implementation(deps.hilt.core)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
// region Other Dependencies
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
// 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)
// endregion
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.account.converter
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWalletId
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 cryptoPortfolioCF factory for creating a crypto portfolio converter
*
* @constructor Creates an instance of the container with injected factories.
*
[REDACTED_AUTHOR]
*/
internal class AccountConverterFactoryContainer @Inject constructor(
val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
private val accountsListCF: AccountListConverter.Factory,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userWalletsStore: UserWalletsStore,
) {
fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return accountsListCF.create(userWallet)
}
fun createCryptoPortfolioConverter(userWalletId: UserWalletId): CryptoPortfolioConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return cryptoPortfolioCF.create(userWallet)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.account.converter
import arrow.core.getOrElse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
internal fun String.toAccountId(userWalletId: UserWalletId): AccountId {
return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse {
error("Unable to create AccountId from value: $this. Cause: $it")
}
}
internal fun String.toAccountName(): AccountName {
return AccountName(value = this).getOrElse {
error("Unable to create AccountName from value: $this. Cause: $it")
}
}
internal fun WalletAccountDTO.toIcon(): CryptoPortfolioIcon {
return CryptoPortfolioIconConverter.convert(
value = CryptoPortfolioIconConverter.DataModel(icon = icon, color = iconColor),
)
}
internal fun Int.toDerivationIndex(): DerivationIndex {
return DerivationIndex(value = this).getOrElse {
error("Unable to create DerivationIndex from value: $this. Cause: $it")
}
}

View file

@ -0,0 +1,46 @@
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.wallet.UserWallet
import com.tangem.utils.converter.Converter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Converts a [GetWalletAccountsResponse] to an [AccountList] and vice versa
*
* @property userWallet the user wallet associated with the account list
* @param cryptoPortfolioConverterFactory factory to create [CryptoPortfolioConverter] instances
*
[REDACTED_AUTHOR]
*/
internal class AccountListConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory,
) : Converter<GetWalletAccountsResponse, AccountList> {
private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy {
cryptoPortfolioConverterFactory.create(userWallet)
}
override fun convert(value: GetWalletAccountsResponse): AccountList {
return AccountList(
userWallet = userWallet,
accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(),
totalAccounts = value.wallet.totalAccounts,
sortType = TokensSortTypeConverter.convert(value.wallet.sort),
groupType = TokensGroupTypeConverter.convert(value.wallet.group),
)
.getOrElse {
error("Failed to convert GetWalletAccountsResponse to AccountList: $it")
}
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): AccountListConverter
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.converter.Converter
/**
* Converts a [WalletAccountDTO] to an [ArchivedAccount]
*
* @param userWalletId the ID of the user wallet associated with the account
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountConverter(
private val userWalletId: UserWalletId,
) : Converter<WalletAccountDTO, ArchivedAccount> {
override fun convert(value: WalletAccountDTO): ArchivedAccount {
return ArchivedAccount(
accountId = value.id.toAccountId(userWalletId = userWalletId),
name = value.name.toAccountName(),
icon = value.toIcon(),
derivationIndex = value.derivationIndex.toDerivationIndex(),
tokensCount = value.totalTokens ?: error("Total tokens should not be null"),
networksCount = value.totalNetworks ?: error("Total networks should not be null"),
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.account.converter
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.TwoWayConverter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Converts a [WalletAccountDTO] to an [Account.CryptoPortfolio] and vise versa
*
* @property userWallet the user wallet associated with the account list
* @property responseCryptoCurrenciesFactory factory to create crypto currencies from response tokens
*
[REDACTED_AUTHOR]
*/
internal class CryptoPortfolioConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val userTokensResponseFactory: UserTokensResponseFactory,
) : TwoWayConverter<WalletAccountDTO, Account.CryptoPortfolio> {
override fun convert(value: WalletAccountDTO): Account.CryptoPortfolio {
val tokens = value.tokens ?: error("Tokens should not be null")
return Account.CryptoPortfolio(
accountId = value.id.toAccountId(userWallet.walletId),
accountName = value.name.toAccountName(),
icon = value.toIcon(),
derivationIndex = value.derivationIndex.toDerivationIndex(),
cryptoCurrencies = if (tokens.isNotEmpty()) {
responseCryptoCurrenciesFactory.createCurrencies(
tokens = tokens,
userWallet = userWallet,
).toSet()
} else {
emptySet()
},
)
}
override fun convertBack(value: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
id = value.accountId.value,
name = value.accountName.value,
derivationIndex = value.derivationIndex.value,
icon = value.icon.value.name,
iconColor = value.icon.color.name,
tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken),
)
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): CryptoPortfolioConverter
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.account.converter
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.converter.Converter
/**
* Converts a [CryptoPortfolioIconConverter.DataModel] to a [CryptoPortfolioIcon]
*
[REDACTED_AUTHOR]
*/
internal object CryptoPortfolioIconConverter : Converter<CryptoPortfolioIconConverter.DataModel, CryptoPortfolioIcon> {
override fun convert(value: DataModel): CryptoPortfolioIcon {
return CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.valueOf(value.icon),
color = CryptoPortfolioIcon.Color.valueOf(value.color),
)
}
data class DataModel(val icon: String, val color: String)
}

View file

@ -0,0 +1,42 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.Converter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
[REDACTED_AUTHOR]
*/
internal class GetWalletAccountsResponseConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory,
) : Converter<AccountList, GetWalletAccountsResponse> {
private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy {
cryptoPortfolioConverterFactory.create(userWallet)
}
override fun convert(value: AccountList): GetWalletAccountsResponse {
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = TokensGroupTypeConverter.convertBack(value.groupType),
sort = TokensSortTypeConverter.convertBack(value.sortType),
totalAccounts = value.totalAccounts,
),
accounts = value.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map(cryptoPortfolioConverter::convertBack),
unassignedTokens = emptyList(),
)
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): GetWalletAccountsResponseConverter
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.utils.converter.Converter
/**
* Converts an [AccountList] to a [SaveWalletAccountsResponse]
*
[REDACTED_AUTHOR]
*/
internal object SaveWalletAccountsResponseConverter : Converter<AccountList, SaveWalletAccountsResponse> {
override fun convert(value: AccountList): SaveWalletAccountsResponse {
return SaveWalletAccountsResponse(
accounts = value.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map(::toDTO),
)
}
private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
id = account.accountId.value,
name = account.accountName.value,
derivationIndex = account.derivationIndex.value,
icon = account.icon.value.name,
iconColor = account.icon.color.name,
)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensGroupType
import com.tangem.utils.converter.TwoWayConverter
/**
* Converts a [UserTokensResponse.GroupType] to a [TokensGroupType] and vice versa
*
[REDACTED_AUTHOR]
*/
internal object TokensGroupTypeConverter : TwoWayConverter<UserTokensResponse.GroupType, TokensGroupType> {
override fun convert(value: UserTokensResponse.GroupType): TokensGroupType {
return when (value) {
UserTokensResponse.GroupType.NETWORK -> TokensGroupType.NETWORK
UserTokensResponse.GroupType.NONE,
UserTokensResponse.GroupType.TOKEN,
-> TokensGroupType.NONE
}
}
override fun convertBack(value: TokensGroupType): UserTokensResponse.GroupType {
return when (value) {
TokensGroupType.NONE -> UserTokensResponse.GroupType.NONE
TokensGroupType.NETWORK -> UserTokensResponse.GroupType.NETWORK
}
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensSortType
import com.tangem.utils.converter.TwoWayConverter
/**
* Converts a [UserTokensResponse.SortType] to a [TokensSortType] and vice versa
*
[REDACTED_AUTHOR]
*/
internal object TokensSortTypeConverter : TwoWayConverter<UserTokensResponse.SortType, TokensSortType> {
override fun convert(value: UserTokensResponse.SortType): TokensSortType {
return when (value) {
UserTokensResponse.SortType.BALANCE -> TokensSortType.BALANCE
UserTokensResponse.SortType.MANUAL,
UserTokensResponse.SortType.MARKETCAP,
-> TokensSortType.NONE
}
}
override fun convertBack(value: TokensSortType): UserTokensResponse.SortType {
return when (value) {
TokensSortType.NONE -> UserTokensResponse.SortType.MANUAL
TokensSortType.BALANCE -> UserTokensResponse.SortType.BALANCE
}
}
}

View file

@ -1,9 +1,16 @@
package com.tangem.data.account.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -16,10 +23,26 @@ internal object AccountDataModule {
@Provides
@Singleton
fun provideAccountsCRUDRepository(userWalletsStore: UserWalletsStore): AccountsCRUDRepository {
fun provideAccountFeatureToggle(featureTogglesManager: FeatureTogglesManager): AccountsFeatureToggles {
return DefaultAccountsFeatureToggles(featureTogglesManager = featureTogglesManager)
}
@Provides
@Singleton
fun provideAccountsCRUDRepository(
tangemTechApi: TangemTechApi,
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userWalletsStore: UserWalletsStore,
accountConverterFactoryContainer: AccountConverterFactoryContainer,
dispatchers: CoroutineDispatcherProvider,
): AccountsCRUDRepository {
return DefaultAccountsCRUDRepository(
runtimeStore = RuntimeSharedStore(),
tangemTechApi = tangemTechApi,
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = ArchivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
convertersContainer = accountConverterFactoryContainer,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.data.account.featuretoggle
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
internal class DefaultAccountsFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : AccountsFeatureToggles {
override val isFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "ACCOUNTS_FEATURE_ENABLED")
}

View file

@ -0,0 +1,142 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.Provider
import timber.log.Timber
import javax.inject.Inject
/**
* Handles errors that occur during the fetching of wallet accounts
*
* @property userTokensSaver saves user tokens to the storage
* @property userWalletsStore provides access to user wallet data
* @property userTokensResponseStore provides access to user token responses.
* @property cryptoPortfolioCF factory for converting crypto portfolios
* @property userTokensResponseFactory factory for creating user token responses
* @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets
*
* @see DefaultWalletAccountsFetcher
*
[REDACTED_AUTHOR]
*/
internal class FetchWalletAccountsErrorHandler @Inject constructor(
private val userTokensSaver: UserTokensSaver,
private val userWalletsStore: UserWalletsStore,
private val userTokensResponseStore: UserTokensResponseStore,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
) {
/**
* Handles the error that occurred during the fetching of wallet accounts.
* [pushWalletAccounts] and [storeWalletAccounts] are functions that passed as parameters to avoid
* cyclic dependencies.
*
* @param error the error that occurred
* @param userWalletId the ID of the user wallet
* @param savedAccountsResponse the previously saved wallet accounts response, if available
* @param pushWalletAccounts function to push wallet accounts to the server
* @param storeWalletAccounts function to store wallet accounts locally
*/
suspend fun handle(
error: ApiResponseError,
userWalletId: UserWalletId,
savedAccountsResponse: GetWalletAccountsResponse?,
pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List<WalletAccountDTO>) -> Unit,
storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit,
) {
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
}
val userWalletProvider = Provider { userWalletsStore.getSyncStrict(key = userWalletId) }
val accountDTOs = savedAccountsResponse?.accounts.orDefault(userWalletProvider = userWalletProvider)
val userTokensResponse = savedAccountsResponse?.toUserTokensResponse()
.orFromLegacyStore(userWalletProvider = userWalletProvider)
.orDefault(userWalletProvider = userWalletProvider)
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) {
pushWalletAccounts(userWalletId, accountDTOs)
userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse)
}
val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse)
storeWalletAccounts(userWalletId, response)
}
private fun List<WalletAccountDTO>?.orDefault(userWalletProvider: Provider<UserWallet>): List<WalletAccountDTO> {
if (this != null) return this
val userWallet = userWalletProvider()
val accounts = AccountList.empty(userWallet).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val converter = cryptoPortfolioCF.create(userWallet = userWallet)
return converter.convertListBack(input = accounts)
}
private suspend fun UserTokensResponse?.orFromLegacyStore(
userWalletProvider: Provider<UserWallet>,
): UserTokensResponse? {
if (this != null) return this
val userWalletId = userWalletProvider().walletId
return userTokensResponseStore.getSyncOrNull(userWalletId)
.also { userTokensResponseStore.clear(userWalletId) }
}
private fun UserTokensResponse?.orDefault(userWalletProvider: Provider<UserWallet>): UserTokensResponse {
if (this != null) return this
return userTokensResponseFactory.createUserTokensResponse(
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(
userWallet = userWalletProvider(),
),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
private fun GetWalletAccountsResponse?.orDefault(
userWalletId: UserWalletId,
accountDTOs: List<WalletAccountDTO>,
userTokensResponse: UserTokensResponse,
): GetWalletAccountsResponse {
if (this != null) return this
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = userTokensResponse.group,
sort = userTokensResponse.sort,
totalAccounts = accountDTOs.size,
),
accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens),
unassignedTokens = emptyList(),
)
}
}

View file

@ -1,90 +1,147 @@
package com.tangem.data.account.repository
import arrow.core.Option
import arrow.core.Option.Companion.catch
import arrow.core.none
import arrow.core.raise.option
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import arrow.core.toOption
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.converter.ArchivedAccountConverter
import com.tangem.data.account.converter.SaveWalletAccountsResponseConverter
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.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
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.account.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.account.*
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
[REDACTED_AUTHOR]
*/
// TODO: [REDACTED_JIRA]
internal class DefaultAccountsCRUDRepository(
private val runtimeStore: RuntimeSharedStore<List<AccountList>>,
private val tangemTechApi: TangemTechApi,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory,
private val userWalletsStore: UserWalletsStore,
private val convertersContainer: AccountConverterFactoryContainer,
private val dispatchers: CoroutineDispatcherProvider,
) : AccountsCRUDRepository {
override suspend fun getAccounts(userWalletId: UserWalletId): Option<AccountList> = catch {
runtimeStore.getSyncOrNull()
?.firstOrNull { it.userWallet.walletId == userWalletId }
?: return none()
private val saveAccountsMutex = Mutex()
override suspend fun getAccountListSync(userWalletId: UserWalletId): Option<AccountList> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
ensureNotNull(accountListResponse)
val converter = convertersContainer.createAccountListConverter(userWalletId = userWalletId)
converter.convert(value = accountListResponse)
}
override suspend fun getAccount(accountId: AccountId): Option<Account.CryptoPortfolio> = catch {
runtimeStore.getSyncOrNull().orEmpty()
.flatMap { it.accounts }
.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio
?: return none()
override suspend fun getAccountSync(accountId: AccountId): Option<Account.CryptoPortfolio> = option {
val userWalletId = accountId.userWalletId
val accountResponse = getAccountsResponseSync(userWalletId = userWalletId)
?.accounts?.firstOrNull { it.id == accountId.value }
ensureNotNull(accountResponse)
val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = userWalletId)
converter.convert(value = accountResponse)
}
override suspend fun getArchivedAccount(accountId: AccountId): Option<ArchivedAccount> = option {
createMockArchivedAccount(userWalletId = accountId.userWalletId)
override suspend fun getArchivedAccountSync(accountId: AccountId): Option<ArchivedAccount> {
val store = getArchivedAccountsStore(userWalletId = accountId.userWalletId)
return store.getSyncOrNull()
?.firstOrNull { it.accountId == accountId }
.toOption()
}
override suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>> = option {
listOf(
createMockArchivedAccount(userWalletId),
)
override suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>> {
val store = getArchivedAccountsStore(userWalletId = userWalletId)
return store.getSyncOrNull().toOption()
}
override fun getArchivedAccounts(userWalletId: UserWalletId): Flow<List<ArchivedAccount>> {
return flow {
getArchivedAccountsSync(userWalletId).getOrNull().orEmpty()
}
val store = getArchivedAccountsStore(userWalletId = userWalletId)
return store.get()
}
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) = Unit
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) {
val response = withContext(dispatchers.io) {
tangemTechApi.getWalletArchivedAccounts(walletId = userWalletId.stringValue).getOrThrow()
}
val store = getArchivedAccountsStore(userWalletId = userWalletId)
val converter = ArchivedAccountConverter(userWalletId = userWalletId)
val archivedAccounts = converter.convertList(input = response.accounts)
store.store(value = archivedAccounts)
}
override suspend fun saveAccounts(accountList: AccountList) {
runtimeStore.update(emptyList()) {
it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId }
saveAccountsMutex.withLock {
val store = getAccountsResponseStore(userWalletId = accountList.userWallet.walletId)
val version = store.data.firstOrNull()?.wallet?.version ?: 0
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
withContext(dispatchers.io) {
tangemTechApi.saveWalletAccounts(
walletId = accountList.userWallet.walletId.stringValue,
ifMatch = version.toString(),
body = body,
)
.getOrThrow()
}
val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet)
val accountsResponse = converter.convert(value = accountList)
store.updateData { accountsResponse }
}
}
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int {
val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option<Int> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
return activeAccountsCount + 1
ensureNotNull(accountListResponse)
return accountListResponse.wallet.totalAccounts.toOption()
}
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsStore.getSyncStrict(userWalletId)
}
private fun createMockArchivedAccount(userWalletId: UserWalletId): ArchivedAccount {
val derivationIndex = DerivationIndex(value = 1000).getOrNull()!!
private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? {
val store = getAccountsResponseStore(userWalletId = userWalletId)
return store.data.firstOrNull()
}
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = derivationIndex,
),
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = derivationIndex,
tokensCount = 2,
networksCount = 1,
)
private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore {
return accountsResponseStoreFactory.create(userWalletId = userWalletId)
}
private fun getArchivedAccountsStore(userWalletId: UserWalletId): ArchivedAccountsStore {
return archivedAccountsStoreFactory.create(userWalletId)
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.account.store
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
/**
* Factory class for creating and managing instances of [AccountsResponseStore].
* This class is responsible for creating a [DataStore] for each unique [UserWalletId].
*
* @property context application context used to access the file system
* @property moshi moshi instance for JSON serialization and deserialization
* @property dispatchers coroutine dispatcher provider
*
[REDACTED_AUTHOR]
*/
internal class AccountsResponseStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
@NetworkMoshi private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) {
@OptIn(ExperimentalStdlibApi::class)
private val adapter by lazy { moshi.adapter<GetWalletAccountsResponse?>() }
private val createdDataStores = ConcurrentHashMap<UserWalletId, AccountsResponseStore>()
/**
* Creates or retrieves an [AccountsResponseStore] for the given [UserWalletId].
*
* @param userWalletId the unique identifier of the user's wallet
*/
fun create(userWalletId: UserWalletId): AccountsResponseStore {
return createdDataStores.computeIfAbsent(userWalletId) {
DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter),
produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, AccountsResponseStore> = createdDataStores.toMap()
@VisibleForTesting
fun clearStores() {
createdDataStores.clear()
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlin.time.Duration.Companion.seconds
/**
* Store for managing archived accounts with support for data expiration
*
* @property runtimeStore the underlying runtime shared store for storing the list of archived accounts
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountsStore(
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?>,
) {
private var timestamp: Long? = null
/** Retrieves a flow of archived accounts, filtering out null values */
fun get(): Flow<List<ArchivedAccount>> {
return runtimeStore.get()
.map {
if (isDataExpired()) null else it
}
.filterNotNull()
}
/** Retrieves the list of archived accounts synchronously, or null if the data is expired */
suspend fun getSyncOrNull(): List<ArchivedAccount>? {
if (isDataExpired()) return null
return runtimeStore.getSyncOrNull()
}
/** Stores the provided list of archived accounts [value] */
suspend fun store(value: List<ArchivedAccount>) {
timestamp = System.currentTimeMillis()
runtimeStore.store(value)
}
private fun isDataExpired(): Boolean {
val currentTime = System.currentTimeMillis()
val storedTime = timestamp ?: return true
return currentTime - storedTime >= EXPIRATION_DURATION_MS
}
@VisibleForTesting
fun setTimestamp(time: Long) {
timestamp = time
}
@VisibleForTesting
fun clear() {
timestamp = null
runtimeStore.clear()
}
private companion object Companion {
val EXPIRATION_DURATION_MS = 120.seconds.inWholeMicroseconds
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.wallet.UserWalletId
import java.util.concurrent.ConcurrentHashMap
/**
* Factory for creating and managing instances of [ArchivedAccountsStore].
* and reused for each unique [UserWalletId].
*
[REDACTED_AUTHOR]
*/
internal object ArchivedAccountsStoreFactory {
private val createdRuntimeStores = ConcurrentHashMap<UserWalletId, ArchivedAccountsStore>()
/**
* Creates or retrieves an existing instance of [ArchivedAccountsStore] for the given [userWalletId].
*
* @param userWalletId the unique identifier for the user wallet
*/
fun create(userWalletId: UserWalletId): ArchivedAccountsStore {
return createdRuntimeStores.computeIfAbsent(userWalletId) {
ArchivedAccountsStore(runtimeStore = RuntimeStateStore(defaultValue = null))
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, ArchivedAccountsStore> = createdRuntimeStores.toMap()
@VisibleForTesting
fun clearStores() {
createdRuntimeStores.clear()
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.data.account.utils
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.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]
*
* @param userWalletId the ID of the user wallet
*
* @return a new [GetWalletAccountsResponse]` with tokens assigned to the wallet accounts
*/
internal fun GetWalletAccountsResponse.assignTokens(userWalletId: UserWalletId): GetWalletAccountsResponse {
return copy(
accounts = accounts.assignTokens(userWalletId = userWalletId, tokens = unassignedTokens),
unassignedTokens = emptyList(),
)
}
/**
* Assigns tokens from a [UserTokensResponse] to a list of wallet accounts
*
* @param userWalletId the ID of the user wallet
* @param tokens tokens to be assigned
*
* @return a new list of [WalletAccountDTO] with tokens assigned to each account
*/
internal fun List<WalletAccountDTO>.assignTokens(
userWalletId: UserWalletId,
tokens: List<UserTokensResponse.Token>,
): List<WalletAccountDTO> {
val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens)
.groupBy { it.accountId }
return map { accountDTO ->
accountDTO.copy(
tokens = enrichedTokens[accountDTO.id].orEmpty(),
)
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
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
internal fun createWalletAccountDTO(
userWalletId: UserWalletId,
accountId: String? = null,
accountName: String? = null,
icon: String? = null,
iconColor: String? = null,
derivationIndex: Int? = null,
tokens: List<UserTokensResponse.Token>? = emptyList(),
): WalletAccountDTO {
val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
return WalletAccountDTO(
id = accountId ?: mainAccount.accountId.value,
name = accountName ?: mainAccount.accountName.value,
derivationIndex = derivationIndex ?: mainAccount.derivationIndex.value,
icon = icon ?: mainAccount.icon.value.name,
iconColor = iconColor ?: mainAccount.icon.color.name,
tokens = tokens,
)
}
internal fun createCryptoPortfolio(userWalletId: UserWalletId): Account.CryptoPortfolio {
return Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
}
internal fun createGetWalletAccountsResponse(
userWalletId: UserWalletId,
groupType: UserTokensResponse.GroupType = UserTokensResponse.GroupType.NETWORK,
sortType: UserTokensResponse.SortType = UserTokensResponse.SortType.BALANCE,
accountId: String? = null,
accountName: String? = null,
icon: String? = null,
iconColor: String? = null,
derivationIndex: Int? = null,
tokens: List<UserTokensResponse.Token>? = emptyList(),
): GetWalletAccountsResponse {
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 0,
group = groupType,
sort = sortType,
totalAccounts = 1,
),
accounts = buildList {
createWalletAccountDTO(
userWalletId = userWalletId,
accountId = accountId,
accountName = accountName,
icon = icon,
iconColor = iconColor,
derivationIndex = derivationIndex,
tokens = tokens,
)
.let(::add)
},
unassignedTokens = emptyList(),
)
}
internal fun createAccountList(
userWallet: UserWallet,
sortType: TokensSortType = TokensSortType.BALANCE,
groupType: TokensGroupType = TokensGroupType.NETWORK,
): AccountList {
return AccountList(
userWallet = userWallet,
accounts = setOf(createCryptoPortfolio(userWallet.walletId)),
totalAccounts = 1,
sortType = sortType,
groupType = groupType,
)
.getOrNull()!!
}

View file

@ -0,0 +1,159 @@
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
import com.tangem.domain.models.TokensGroupType
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 io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AccountListConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val cryptoPortfolioConverterFactory = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val converter = AccountListConverter(userWallet, cryptoPortfolioConverterFactory)
@BeforeAll
fun setupAll() {
every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
clearMocks(cryptoPortfolioConverter)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val dto = createGetWalletAccountsResponse(userWallet.walletId)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convert(any()) } throws exception
// Act
val actual = runCatching { converter.convert(dto) }.exceptionOrNull()!!
// Asset
val expected = exception
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual.message).isEqualTo(expected.message)
}
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Arrange
if (model.expected.isSuccess) {
model.value.accounts.forEach { dto ->
val account = model.expected.getOrNull()!!.accounts
.firstOrNull { it.accountId.value == dto.id } as? Account.CryptoPortfolio
every { cryptoPortfolioConverter.convert(dto) } returns account!!
}
}
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.BALANCE,
groupType = UserTokensResponse.GroupType.NETWORK,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
),
),
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MANUAL,
groupType = UserTokensResponse.GroupType.TOKEN,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
),
),
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MARKETCAP,
groupType = UserTokensResponse.GroupType.NONE,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
),
),
ConvertModel(
value = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 1,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
),
expected = Result.failure(
IllegalStateException(
"Failed to convert GetWalletAccountsResponse to AccountList: EmptyAccountsList: " +
"The accounts list cannot be empty",
),
),
),
)
}
}
data class ConvertModel(
val value: GetWalletAccountsResponse,
val expected: Result<AccountList>,
)
}

View file

@ -0,0 +1,144 @@
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
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 org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountConverterTest {
private val userWalletId = UserWalletId("011")
private val converter = ArchivedAccountConverter(userWalletId = userWalletId)
@ParameterizedTest
@ProvideTestModels
fun convert(model: TestModel) {
// Act
val actual = runCatching { converter.convert(value = model.value) }
// Assert
actual
.onSuccess {
val expected = model.expected.getOrNull()!!
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<TestModel> {
return listOf(
TestModel(
value = createDTO(),
expected = Result.success(createDomain()),
),
TestModel(
value = createDTO(accountId = "123"),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}",
),
),
),
TestModel(
value = createDTO(name = ""),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}",
),
),
),
TestModel(
value = createDTO(icon = "INVALID_ICON"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
TestModel(
value = createDTO(iconColor = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
TestModel(
value = createDTO(derivationIndex = -1),
expected = Result.failure(
IllegalStateException(
"Unable to create DerivationIndex from value: -1. " +
"Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1",
),
),
),
TestModel(
value = createDTO(totalTokens = null),
expected = Result.failure(
IllegalStateException("Total tokens should not be null"),
),
),
TestModel(
value = createDTO(totalNetworks = null),
expected = Result.failure(
IllegalStateException("Total networks should not be null"),
),
),
)
}
private fun createDTO(
accountId: String = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027",
name: String = "Test Account",
icon: String = "Letter",
iconColor: String = "Azure",
derivationIndex: Int = 0,
totalTokens: Int? = 1,
totalNetworks: Int? = 1,
): WalletAccountDTO {
return WalletAccountDTO(
id = accountId,
name = name,
derivationIndex = derivationIndex,
icon = icon,
iconColor = iconColor,
tokens = null,
totalTokens = totalTokens,
totalNetworks = totalNetworks,
)
}
private fun createDomain(): ArchivedAccount {
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(0).getOrNull()!!),
name = "Test Account".toAccountName(),
derivationIndex = 0.toDerivationIndex(),
icon = CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
tokensCount = 1,
networksCount = 1,
)
}
data class TestModel(
val value: WalletAccountDTO,
val expected: Result<ArchivedAccount>,
)
}

View file

@ -0,0 +1,155 @@
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
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 io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CryptoPortfolioConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
private val userTokensResponseFactory: UserTokensResponseFactory = mockk()
private val converter = CryptoPortfolioConverter(
userWallet = userWallet,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
userTokensResponseFactory = userTokensResponseFactory,
)
@BeforeEach
fun setupEach() {
clearMocks(responseCryptoCurrenciesFactory, userTokensResponseFactory)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId),
expected = Result.success(createCryptoPortfolio(userWalletId = userWallet.walletId)),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountId = "123"),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = ""),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, icon = "INVALID_ICON"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, iconColor = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, derivationIndex = -1),
expected = Result.failure(
IllegalStateException(
"Unable to create DerivationIndex from value: -1. " +
"Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, tokens = null),
expected = Result.failure(
IllegalStateException("Tokens should not be null"),
),
),
)
}
}
data class ConvertModel(
val value: WalletAccountDTO,
val expected: Result<Account.CryptoPortfolio>,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = converter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertBackModel> {
return listOf(
ConvertBackModel(
value = createCryptoPortfolio(userWalletId = userWallet.walletId),
expected = createWalletAccountDTO(userWalletId = userWallet.walletId),
),
)
}
}
data class ConvertBackModel(
val value: Account.CryptoPortfolio,
val expected: WalletAccountDTO,
)
}

View file

@ -0,0 +1,66 @@
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 org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CryptoPortfolioIconConverterTest {
@ParameterizedTest
@ProvideTestModels
fun convert(model: TestModel) {
// Act
val actual = runCatching { CryptoPortfolioIconConverter.convert(model.value) }
// Assert
actual
.onSuccess {
val expected = model.expected.getOrNull()!!
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<TestModel> {
return listOf(
TestModel(
value = DataModel(icon = "Letter", color = "Azure"),
expected = Result.success(
CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
),
),
TestModel(
value = DataModel(icon = "INVALID_ICON", color = "Azure"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
TestModel(
value = DataModel(icon = "Letter", color = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
)
}
data class TestModel(
val value: DataModel,
val expected: Result<CryptoPortfolioIcon>,
)
}

View file

@ -0,0 +1,129 @@
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
import com.tangem.domain.models.TokensGroupType
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 io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetWalletAccountsResponseConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val cryptoPortfolioConverterFactory = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val converter = GetWalletAccountsResponseConverter(
userWallet = userWallet,
cryptoPortfolioConverterFactory = cryptoPortfolioConverterFactory,
)
@BeforeAll
fun setupAll() {
every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
clearMocks(cryptoPortfolioConverter)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val domain = createAccountList(userWallet = userWallet)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convertBack(any()) } throws exception
// Act
val actual = runCatching { converter.convert(domain) }.exceptionOrNull()!!
// Asset
val expected = exception
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual.message).isEqualTo(expected.message)
}
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Arrange
if (model.expected.isSuccess) {
model.value.accounts.forEach { domain ->
val dto = model.expected.getOrNull()!!.accounts.firstOrNull { it.id == domain.accountId.value }
every { cryptoPortfolioConverter.convertBack(domain as Account.CryptoPortfolio) } returns dto!!
}
}
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createAccountList(
userWallet = userWallet,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
expected = Result.success(
createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.BALANCE,
groupType = UserTokensResponse.GroupType.NETWORK,
),
),
),
ConvertModel(
value = createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
expected = Result.success(
createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MANUAL,
groupType = UserTokensResponse.GroupType.NONE,
),
),
),
)
}
}
data class ConvertModel(
val value: AccountList,
val expected: Result<GetWalletAccountsResponse>,
)
}

View file

@ -0,0 +1,50 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SaveWalletAccountsResponseConverterTest {
@Test
fun convert() {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns UserWalletId("011")
}
val accountList = AccountList(
userWallet = userWallet,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)),
totalAccounts = 1,
)
.getOrNull()!!
// Act
val actual = SaveWalletAccountsResponseConverter.convert(value = accountList)
// Assert
val expected = SaveWalletAccountsResponse(
accounts = listOf(
WalletAccountDTO(
id = accountList.mainAccount.accountId.value,
name = accountList.mainAccount.accountName.value,
derivationIndex = accountList.mainAccount.derivationIndex.value,
icon = accountList.mainAccount.icon.value.name,
iconColor = accountList.mainAccount.icon.color.name,
),
),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -0,0 +1,85 @@
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 org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TokensGroupTypeConverterTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = TokensGroupTypeConverter.convert(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = UserTokensResponse.GroupType.NETWORK,
expected = TokensGroupType.NETWORK,
),
ConvertModel(
value = UserTokensResponse.GroupType.NONE,
expected = TokensGroupType.NONE,
),
ConvertModel(
value = UserTokensResponse.GroupType.TOKEN,
expected = TokensGroupType.NONE,
),
)
}
}
data class ConvertModel(
val value: UserTokensResponse.GroupType,
val expected: TokensGroupType,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = TokensGroupTypeConverter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertBackModel> {
return listOf(
ConvertBackModel(
value = TokensGroupType.NETWORK,
expected = UserTokensResponse.GroupType.NETWORK,
),
ConvertBackModel(
value = TokensGroupType.NONE,
expected = UserTokensResponse.GroupType.NONE,
),
)
}
}
data class ConvertBackModel(
val value: TokensGroupType,
val expected: UserTokensResponse.GroupType,
)
}

View file

@ -0,0 +1,81 @@
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 org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TokensSortTypeConverterTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = TokensSortTypeConverter.convert(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
fun provideTestModels() = listOf(
ConvertModel(
value = UserTokensResponse.SortType.BALANCE,
expected = TokensSortType.BALANCE,
),
ConvertModel(
value = UserTokensResponse.SortType.MANUAL,
expected = TokensSortType.NONE,
),
ConvertModel(
value = UserTokensResponse.SortType.MARKETCAP,
expected = TokensSortType.NONE,
),
)
}
data class ConvertModel(
val value: UserTokensResponse.SortType,
val expected: TokensSortType,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = TokensSortTypeConverter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
fun provideTestModels() = listOf(
ConvertBackModel(
value = TokensSortType.BALANCE,
expected = UserTokensResponse.SortType.BALANCE,
),
ConvertBackModel(
value = TokensSortType.NONE,
expected = UserTokensResponse.SortType.MANUAL,
),
)
}
data class ConvertBackModel(
val value: TokensSortType,
val expected: UserTokensResponse.SortType,
)
}

View file

@ -0,0 +1,239 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FetchWalletAccountsErrorHandlerTest {
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory: UserTokensResponseFactory = mockk()
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val handler = FetchWalletAccountsErrorHandler(
userTokensSaver = userTokensSaver,
userWalletsStore = userWalletsStore,
userTokensResponseStore = userTokensResponseStore,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
)
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
@BeforeEach
fun setupEach() {
clearMocks(
userTokensSaver,
userWalletsStore,
userTokensResponseStore,
cryptoPortfolioCF,
cryptoPortfolioConverter,
cardCryptoCurrencyFactory,
)
}
@Test
fun `does not update accounts when response is up to date`() = runTest {
// Arrange
val error = ApiResponseError.HttpException(
code = Code.NOT_MODIFIED,
message = "Not Modified",
errorBody = null,
)
val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> Unit = mockk()
val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk()
// Act
handler.handle(
error = error,
userWalletId = userWalletId,
savedAccountsResponse = null,
pushWalletAccounts = pushWalletAccounts,
storeWalletAccounts = storeWalletAccounts,
)
// Assert
coVerify(inverse = true) {
userWalletsStore.getSyncStrict(key = any())
userTokensResponseStore.getSyncOrNull(userWalletId = any())
userTokensResponseFactory.createUserTokensResponse(any(), any(), any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any())
cryptoPortfolioCF.create(any())
cryptoPortfolioConverter.convertListBack(any())
pushWalletAccounts(any(), any())
userTokensSaver.push(userWalletId = any(), response = any())
storeWalletAccounts(any(), any())
}
}
@Test
fun `pushes and stores accounts when NOT_FOUND error occurs`() = runTest {
// Arrange
val error = ApiResponseError.HttpException(
code = Code.NOT_FOUND,
message = "Not Found",
errorBody = null,
)
val accountDTO = WalletAccountDTO(
id = "nibh",
name = "Michael Dotson",
derivationIndex = 7135,
icon = "consectetuer",
iconColor = "ferri",
tokens = listOf(),
totalTokens = 7738,
totalNetworks = 3348,
)
val savedAccountsResponse = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
),
accounts = listOf(accountDTO),
unassignedTokens = emptyList(),
)
val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> Unit = mockk(relaxed = true)
val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true)
// Act
handler.handle(
error = error,
userWalletId = userWalletId,
savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = pushWalletAccounts,
storeWalletAccounts = storeWalletAccounts,
)
// Assert
coVerify {
pushWalletAccounts(userWalletId, listOf(accountDTO))
userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse())
storeWalletAccounts(userWalletId, savedAccountsResponse)
}
coVerify(inverse = true) {
userWalletsStore.getSyncStrict(key = any())
userTokensResponseStore.getSyncOrNull(userWalletId = any())
userTokensResponseFactory.createUserTokensResponse(any(), any(), any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any())
cryptoPortfolioCF.create(any())
cryptoPortfolioConverter.convertListBack(any())
}
}
@Test
fun `uses default accounts when savedAccountsResponse is null`() = runTest {
// Arrange
val error = ApiResponseError.TimeoutException
val accounts = AccountList.empty(userWallet).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val accountDTO = WalletAccountDTO(
id = "nibh",
name = "Michael Dotson",
derivationIndex = 7135,
icon = "consectetuer",
iconColor = "ferri",
tokens = listOf(),
totalTokens = 7738,
totalNetworks = 3348,
)
val savedAccountsResponse = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
),
accounts = listOf(accountDTO),
unassignedTokens = emptyList(),
)
val userTokensResponse = savedAccountsResponse.toUserTokensResponse()
every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet
every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO)
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns userTokensResponse
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList()
val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> Unit = mockk(relaxed = true)
val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true)
// Act
handler.handle(
error = error,
userWalletId = userWalletId,
savedAccountsResponse = null,
pushWalletAccounts = pushWalletAccounts,
storeWalletAccounts = storeWalletAccounts,
)
// Assert
coVerify {
userWalletsStore.getSyncStrict(userWalletId)
cryptoPortfolioCF.create(userWallet)
cryptoPortfolioConverter.convertListBack(accounts)
userTokensResponseStore.getSyncOrNull(userWalletId)
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet)
storeWalletAccounts(userWalletId, any())
}
coVerify(inverse = true) {
pushWalletAccounts(any(), any())
userTokensSaver.push(userWalletId = any(), response = any())
}
}
private companion object {
val userWalletId = UserWalletId("011")
}
}

View file

@ -0,0 +1,664 @@
package com.tangem.data.account.repository
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.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.Account.CryptoPortfolio
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import kotlin.time.Duration.Companion.minutes
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultAccountsCRUDRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
private val accountsResponseStore: AccountsResponseStore = mockk()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory = mockk()
private val archivedAccountsInnerStore = RuntimeStateStore<List<ArchivedAccount>?>(defaultValue = null)
private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore)
private val userWalletsStore: UserWalletsStore = mockk()
private val convertersContainer: AccountConverterFactoryContainer = mockk()
private val accountListConverter: AccountListConverter = mockk()
private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk()
private val repository = DefaultAccountsCRUDRepository(
tangemTechApi = tangemTechApi,
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = archivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
convertersContainer = convertersContainer,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("011")
@BeforeAll
fun setup() {
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
every { convertersContainer.createAccountListConverter(userWalletId) } returns accountListConverter
every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
every { archivedAccountsStoreFactory.create(userWalletId) } returns archivedAccountsStore
}
@AfterEach
fun tearDownEach() {
accountsResponseStoreFlow.value = null
archivedAccountsInnerStore.clear()
clearMocks(
tangemTechApi,
archivedAccountsStoreFactory,
accountListConverter,
cryptoPortfolioConverter,
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetAccountListSync {
@Test
fun `getAccounts should return None when account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val actual = repository.getAccountListSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { accountListConverter.convert(value = any()) }
}
@Test
fun `getAccounts should return AccountList when account list response is not null`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse>()
val accountList = mockk<AccountList>()
accountsResponseStoreFlow.value = response
every { accountListConverter.convert(response) } returns accountList
// Act
val actual = repository.getAccountListSync(userWalletId)
// Assert
val expected = accountList.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createAccountListConverter(userWalletId = userWalletId)
accountListConverter.convert(response)
}
}
@Test
fun `getAccounts should throw exception if converter throws exception`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse>()
mockk<AccountList>()
accountsResponseStoreFlow.value = response
val exception = Exception("Test error")
every { accountListConverter.convert(response) } throws exception
// Act
val actual = runCatching { repository.getAccountListSync(userWalletId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createAccountListConverter(userWalletId = userWalletId)
accountListConverter.convert(response)
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetAccountSync {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `getAccount should return None when account response is null`() = runTest {
// Arrange
val response = null
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) }
}
@Test
fun `getAccount should return None when accountDto is not found`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns emptyList()
}
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) }
}
@Test
fun `getAccount should return Account_CryptoPortfolio when account response is not null`() = runTest {
// Arrange
val accountDTO = mockk<WalletAccountDTO> {
every { this@mockk.id } returns accountId.value
}
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
accountsResponseStoreFlow.value = response
val cryptoPortfolio = mockk<CryptoPortfolio>()
every { cryptoPortfolioConverter.convert(accountDTO) } returns cryptoPortfolio
// Act
val actual = repository.getAccountSync(accountId)
// Assert
val expected = cryptoPortfolio.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createCryptoPortfolioConverter(userWalletId)
cryptoPortfolioConverter.convert(accountDTO)
}
}
@Test
fun `getAccount should throw exception if converter throws exception`() = runTest {
// Arrange
val accountDTO = mockk<WalletAccountDTO> {
every { this@mockk.id } returns accountId.value
}
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
accountsResponseStoreFlow.value = response
val exception = Exception("Test error")
every { cryptoPortfolioConverter.convert(accountDTO) } throws exception
// Act
val actual = runCatching { repository.getAccountSync(accountId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createCryptoPortfolioConverter(userWalletId)
cryptoPortfolioConverter.convert(accountDTO)
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccountSync {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `getArchivedAccount should return None when archived accounts are null`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should return None when archived account not found`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = listOf())
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should return ArchivedAccount when found`() = runTest {
// Arrange
val archivedAccount = ArchivedAccount(
accountId = accountId,
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex.Main,
tokensCount = 0,
networksCount = 0,
)
archivedAccountsInnerStore.store(value = listOf(archivedAccount))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
val expected = archivedAccount.toOption()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should throws exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccountSync(accountId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccountListSync {
@Test
fun `getArchivedAccountListSync should return None when archived accounts are null`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
// Act
val actual = repository.getArchivedAccountListSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccountListSync should return Option with list when archived accounts exist`() = runTest {
// Arrange
val archivedAccount1 = mockk<ArchivedAccount>()
val archivedAccount2 = mockk<ArchivedAccount>()
val archivedAccounts = listOf(archivedAccount1, archivedAccount2)
archivedAccountsInnerStore.store(value = archivedAccounts)
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountListSync(userWalletId)
// Assert
val expected = archivedAccounts.toOption()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccountListSync should throw exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccountListSync(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccounts {
@Test
fun `getArchivedAccounts should emit empty list when no archived accounts`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId)
// Act
val actual = getEmittedValues(archivedAccountsFlow)
// Assert
Truth.assertThat(actual).isEmpty()
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.get()
}
}
@Test
fun `getArchivedAccounts should emit list of archived accounts when present`() = runTest {
// Arrange
val archivedAccount1 = mockk<ArchivedAccount>()
val archivedAccount2 = mockk<ArchivedAccount>()
val archivedAccounts = listOf(archivedAccount1, archivedAccount2)
archivedAccountsInnerStore.store(value = archivedAccounts)
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId)
// Act
val actual = getEmittedValues(archivedAccountsFlow)
// Assert
val expected = listOf(archivedAccounts)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.get()
}
}
@Test
fun `getArchivedAccounts should throw exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccounts(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class FetchArchivedAccounts {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `fetchArchivedAccounts should store archived accounts in store`() = runTest {
// Arrange
val accountDTO = WalletAccountDTO(
id = accountId.value,
name = "Archived Account",
derivationIndex = 0,
icon = CryptoPortfolioIcon.Icon.Wallet.name,
iconColor = CryptoPortfolioIcon.Color.DullLavender.name,
totalNetworks = 0,
totalTokens = 0,
)
val apiResponse = mockk<GetWalletArchivedAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO)
coEvery {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue)
} returns ApiResponse.Success(apiResponse)
// Act
repository.fetchArchivedAccounts(userWalletId)
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).containsExactly(archivedAccount)
coVerifyOrder {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue)
archivedAccountsStoreFactory.create(userWalletId)
}
}
@Test
fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { // Arrange
val exception = Exception("API error")
coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } throws exception
// Act
val actual = runCatching { repository.fetchArchivedAccounts(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull()
coVerify { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class SaveAccounts {
private val version = 1
@Test
fun `saveAccounts should call API and update store`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.version } returns version
}
accountsResponseStoreFlow.value = accountsResponse
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
val apiResponse = ApiResponse.Success(Unit)
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
ifMatch = version.toString(),
body = body,
)
} returns apiResponse
val converter = mockk<GetWalletAccountsResponseConverter> {
every { this@mockk.convert(accountList) } returns accountsResponse
}
every {
convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet)
} returns converter
coEvery { accountsResponseStore.updateData(transform = any()) } returns accountsResponse
// Act
repository.saveAccounts(accountList)
// Assert
Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse)
coVerifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body)
convertersContainer.getWalletAccountsResponseCF.create(userWallet)
converter.convert(accountList)
accountsResponseStore.updateData(any())
}
}
@Test
fun `saveAccounts if API request is failed`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.version } returns version
}
accountsResponseStoreFlow.value = accountsResponse
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
val apiResponse = ApiResponse.Error(cause = ApiResponseError.NetworkException) as ApiResponse<Unit>
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
ifMatch = version.toString(),
body = body,
)
} returns apiResponse
// Act
val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isEqualTo(ApiResponseError.NetworkException)
coVerifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body)
}
coVerify(inverse = true) {
convertersContainer.getWalletAccountsResponseCF.create(any())
accountsResponseStore.updateData(any())
}
}
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.account.store
import android.content.Context
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AccountsResponseStoreFactoryTest {
private val context: Context = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory(
context = context,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@AfterEach
fun setup() {
clearMocks(context)
factory.clearStores()
}
@Test
fun `creates new data store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("011")
val createdStore = factory.create(userWalletId = userWalletId)
// Actual
val actual = factory.getAllStores()
// Assert
Truth.assertThat(actual).containsExactly(userWalletId, createdStore)
}
@Test
fun `reuses existing data store for same userWalletId`() {
val userWalletId = UserWalletId("011")
// Arrange (first creation)
val firstStore = factory.create(userWalletId = userWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(userWalletId, firstStore)
// Arrange (second creation)
val secondStore = factory.create(userWalletId = userWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
Truth.assertThat(actual2).containsExactly(userWalletId, secondStore)
Truth.assertThat(firstStore).isSameInstanceAs(secondStore)
}
@Test
fun `creates separate data stores for different userWalletIds`() {
// Arrange (first creation)
val firstWalletId = UserWalletId("011")
val firstStore = factory.create(userWalletId = firstWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore)
// Arrange (second creation)
val secondWalletId = UserWalletId("011")
val secondStore = factory.create(userWalletId = secondWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore)
Truth.assertThat(actual2).containsExactlyEntriesIn(expected)
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreFactoryTest {
private val factory = ArchivedAccountsStoreFactory
@AfterEach
fun tearDownEach() {
factory.clearStores()
}
@Test
fun `creates new store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("001")
val createdStore = factory.create(userWalletId)
// Act
val actual = factory.getAllStores()
// Assert
Truth.assertThat(actual).containsExactly(userWalletId, createdStore)
}
@Test
fun `reuses existing data store for same userWalletId`() {
val userWalletId = UserWalletId("011")
// Arrange (first creation)
val firstStore = factory.create(userWalletId = userWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(userWalletId, firstStore)
// Arrange (second creation)
val secondStore = factory.create(userWalletId = userWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
Truth.assertThat(actual2).containsExactly(userWalletId, secondStore)
Truth.assertThat(firstStore).isSameInstanceAs(secondStore)
}
@Test
fun `creates separate data stores for different userWalletIds`() {
// Arrange (first creation)
val firstWalletId = UserWalletId("011")
val firstStore = factory.create(userWalletId = firstWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore)
// Arrange (second creation)
val secondWalletId = UserWalletId("011")
val secondStore = factory.create(userWalletId = secondWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore)
Truth.assertThat(actual2).containsExactlyEntriesIn(expected)
}
}

View file

@ -0,0 +1,139 @@
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
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 kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import kotlin.time.Duration.Companion.seconds
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreTest {
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?> = RuntimeStateStore(defaultValue = null)
private val archivedAccountsStore: ArchivedAccountsStore = ArchivedAccountsStore(runtimeStore = runtimeStore)
@AfterEach
fun tearDown() {
archivedAccountsStore.clear()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Get {
@Test
fun `get returns empty flow`() = runTest {
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
val expected = listOf(archivedAccount)
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetSyncOrnNull {
@Test
fun `getSyncOrNull returns null`() = runTest {
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
}
@Test
fun store() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
// Act
archivedAccountsStore.store(value = listOf(archivedAccount))
val actual = runtimeStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
private fun createArchivedAccount(): ArchivedAccount {
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(
userWalletId = UserWalletId("011"),
derivationIndex = DerivationIndex.Main,
),
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex.Main,
tokensCount = 2,
networksCount = 1,
)
}
}

View file

@ -0,0 +1,324 @@
package com.tangem.data.account.utils
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.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetWalletAccountsResponseExtTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class FlattenTokens {
@Test
fun `flattenTokens returns empty list when accounts are empty`() {
// Arrange
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
)
// Act
val actual = response.flattenTokens()
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `flattenTokens returns empty list when single account has empty tokens`() {
// Arrange
val account = createWalletAccountDTO(derivationIndex = 0)
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
),
accounts = listOf(account),
unassignedTokens = emptyList(),
)
// Act
val actual = response.flattenTokens()
// Assert
Truth.assertThat(actual).isEmpty()
}
@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 account1 = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1, token2))
val account2 = createWalletAccountDTO(derivationIndex = 1, tokens = listOf(token3))
val account3 = createWalletAccountDTO(derivationIndex = 2)
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
),
accounts = listOf(account1, account2, account3),
unassignedTokens = emptyList(),
)
// Act
val actual = response.flattenTokens()
// Assert
Truth.assertThat(actual).containsExactly(token1, token2, token3)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ToUserTokensResponse {
@Test
fun `toUserTokensResponse returns correct UserTokensResponse for empty accounts and unassignedTokens`() {
// Arrange
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
)
// Act
val actual = response.toUserTokensResponse()
// Assert
val expected = UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `toUserTokensResponse includes tokens from accounts and unassignedTokens`() {
// Arrange
val token1 = createUserToken(id = "0")
val token2 = createUserToken(id = "1")
val account = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1))
val unassignedToken = token2
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 1,
),
accounts = listOf(account),
unassignedTokens = listOf(unassignedToken),
)
// Act
val actual = response.toUserTokensResponse()
// Assert
val expected = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(token1),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetWalletAccountsResponseAssignTokens {
@Test
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 account1 = createWalletAccountDTO(derivationIndex = 0)
val account2 = createWalletAccountDTO(derivationIndex = 1)
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
),
accounts = listOf(account1, account2),
unassignedTokens = listOf(token1, token2),
)
// Act
val actual = response.assignTokens(userWalletId)
// Assert
val expected = GetWalletAccountsResponse(
wallet = response.wallet,
accounts = listOf(
account1.copy(
tokens = listOf(
token1.copy(accountId = accountId),
token2.copy(accountId = accountId),
),
),
account2,
),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `assignTokens does not change accounts if there are no unassignedTokens`() {
// Arrange
val account = createWalletAccountDTO(derivationIndex = 0)
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
),
accounts = listOf(account),
unassignedTokens = emptyList(),
)
// Act
val actual = response.assignTokens(userWalletId)
// Assert
val expected = GetWalletAccountsResponse(
wallet = response.wallet,
accounts = listOf(account),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class WalletAccountDTOListAssignTokens {
@Test
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 account1 = createWalletAccountDTO(derivationIndex = 0)
val account2 = createWalletAccountDTO(derivationIndex = 1)
// Act
val actual = listOf(account1, account2).assignTokens(
userWalletId = userWalletId,
tokens = listOf(token1, token2),
)
// Assert
val expected = listOf(
account1.copy(
tokens = listOf(
token1.copy(accountId = accountId),
token2.copy(accountId = accountId),
),
),
account2,
)
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `assignTokens does not change accounts if there are no unassignedTokens`() {
// Arrange
val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"
val token1 = createUserToken(id = "0", accountId = accountId)
val account1 = createWalletAccountDTO(derivationIndex = 0)
// Act
val actual = listOf(account1).assignTokens(
userWalletId = userWalletId,
tokens = listOf(token1),
)
// Assert
val expected = listOf(
account1.copy(
tokens = listOf(
token1.copy(accountId = accountId),
),
),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}
private fun createWalletAccountDTO(derivationIndex: Int, tokens: List<UserTokensResponse.Token> = emptyList()) =
WalletAccountDTO(
id = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(derivationIndex).getOrNull()!!,
).value,
name = "Name #$derivationIndex",
derivationIndex = derivationIndex,
icon = "icon",
iconColor = "color",
tokens = tokens,
totalTokens = tokens.size,
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,
)
private companion object {
val userWalletId = UserWalletId("011")
}
}

View file

@ -19,19 +19,20 @@ dependencies {
implementation(projects.core.utils)
/* Domain */
implementation(projects.domain.account)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.card)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.notifications.toggles)
implementation(projects.domain.networks)
implementation(projects.domain.wallets)
/* Libs - SDK */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(projects.libs.crypto)
implementation(projects.libs.blockchainSdk)
/* DI */

View file

@ -0,0 +1,40 @@
package com.tangem.data.common.cache.etag
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.models.wallet.UserWalletId
import timber.log.Timber
/**
* Default implementation of the [ETagsStore] interface for managing ETag values
*
* @property appPreferencesStore the preferences store used for saving and retrieving ETag values
*/
internal class DefaultETagsStore(
private val appPreferencesStore: AppPreferencesStore,
) : ETagsStore {
override suspend fun getSyncOrNull(userWalletId: UserWalletId, key: ETagsStore.Key): String? {
val key = getAccountsETagKey(userWalletId = userWalletId, key = key)
return appPreferencesStore.getSyncOrNull(key = key)
}
override suspend fun store(userWalletId: UserWalletId, key: ETagsStore.Key, value: String) {
if (value.isBlank()) {
Timber.e("ETag value is blank, not storing it. userWalletId: $userWalletId, key: $key")
return
}
val key = getAccountsETagKey(userWalletId = userWalletId, key = key)
appPreferencesStore.store(key = key, value = value)
}
private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key<String> {
return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}")
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.data.common.cache.etag
import com.tangem.domain.models.wallet.UserWalletId
/**
* Interface for working with ETag (Entity Tag), which is used for data caching and validation.
*
[REDACTED_AUTHOR]
*/
interface ETagsStore {
/**
* Retrieves the stored ETag value for the specified wallet and key
*
* @param userWalletId identifier of the user wallet
* @param key the key for which to get the ETag value
*/
suspend fun getSyncOrNull(userWalletId: UserWalletId, key: Key): String?
/**
* Stores the ETag value for the specified wallet and key
*
* @param userWalletId identifier of the user wallet
* @param key the key for which to get the ETag value
*/
suspend fun store(userWalletId: UserWalletId, key: Key, value: String)
/** Enumeration of possible keys for storing ETag values */
enum class Key {
WalletAccounts,
UserTokens,
;
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.data.common.currency
import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import timber.log.Timber
/**
* Enriches the [UserTokensResponse] with accountId values for tokens
*
[REDACTED_AUTHOR]
*/
object UserTokensResponseAccountIdEnricher {
/**
* Enriches the tokens in the given [UserTokensResponse] with accountId values
*
* @param userWalletId the ID of the user wallet
* @param response the [UserTokensResponse] containing tokens to be enriched
*/
operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
val enrichedTokens = invoke(userWalletId = userWalletId, tokens = response.tokens)
return response.copy(tokens = enrichedTokens)
}
/**
* Enriches the given list of tokens with accountId values
*
* @param userWalletId the ID of the user wallet
* @param tokens the list of tokens to be enriched
*/
operator fun invoke(
userWalletId: UserWalletId,
tokens: List<UserTokensResponse.Token>,
): List<UserTokensResponse.Token> {
val hasUnassignedTokens = tokens.any { it.accountId == null }
if (!hasUnassignedTokens) return tokens
val enrichedTokens = tokens
.filter { it.accountId == null }
.groupByAccountIndex()
.mapKeysToAccountId(userWalletId)
.mapToEnrichedTokens()
if (enrichedTokens.isEmpty()) return tokens
val enrichedTokenMap = enrichedTokens.associateBy { it }
return tokens.map { token ->
enrichedTokenMap[token] ?: token
}
}
private fun List<UserTokensResponse.Token>.groupByAccountIndex(): Map<Long?, List<UserTokensResponse.Token>> {
return this
.groupBy { savedToken ->
val derivationPathValue = savedToken.derivationPath
if (derivationPathValue == null) {
Timber.e("Token $savedToken has no derivation path")
return@groupBy null
}
val blockchain = Blockchain.fromNetworkId(networkId = savedToken.networkId)
if (blockchain == null) {
Timber.e("Token $savedToken has unknown networkId")
return@groupBy null
}
val accountNodeRecognizer = AccountNodeRecognizer(blockchain)
val accountIndex = accountNodeRecognizer.recognize(derivationPathValue)
if (accountIndex == null) {
Timber.e("Token $savedToken has unrecognized derivation path")
return@groupBy null
}
accountIndex
}
}
private fun Map<Long?, List<UserTokensResponse.Token>>.mapKeysToAccountId(
userWalletId: UserWalletId,
): Map<AccountId?, List<UserTokensResponse.Token>> {
return mapKeys { (accountIndex, _) ->
if (accountIndex == null) return@mapKeys null
val derivationIndex = DerivationIndex.invoke(value = accountIndex.toInt()).getOrElse {
Timber.e("Failed to parse derivation index from account index: $accountIndex")
return@mapKeys null
}
AccountId.forCryptoPortfolio(userWalletId, derivationIndex)
}
}
private fun Map<AccountId?, List<UserTokensResponse.Token>>.mapToEnrichedTokens(): List<UserTokensResponse.Token> {
return flatMap { (accountId, tokens) ->
if (accountId == null) return@flatMap emptyList()
tokens.map { it.copy(accountId = accountId.value) }
}
}
}

View file

@ -5,7 +5,6 @@ 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.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.first
@ -15,17 +14,12 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class UserTokensResponseAddressesEnricher @Inject constructor(
private val notificationsFeatureToggles: NotificationsFeatureToggles,
private val walletsRepository: WalletsRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
) {
suspend operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
if (!notificationsFeatureToggles.isNotificationsEnabled) {
return response
}
val isNotificationsEnabled = walletsRepository.isNotificationsEnabled(userWalletId)
return withContext(dispatchers.default) {

View file

@ -2,8 +2,9 @@ package com.tangem.data.common.currency
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.currency.CryptoCurrency
import javax.inject.Inject
class UserTokensResponseFactory {
class UserTokensResponseFactory @Inject constructor() {
fun createUserTokensResponse(
currencies: List<CryptoCurrency>,

View file

@ -5,60 +5,85 @@ import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
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 kotlinx.coroutines.withContext
import timber.log.Timber
class UserTokensSaver(
private val tangemTechApi: TangemTechApi,
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher,
private val addressesEnricher: UserTokensResponseAddressesEnricher,
private val accountsFeatureToggles: AccountsFeatureToggles,
) {
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.io) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
val enrichedUserTokensResponse = if (useEnricher) {
userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = compatibleUserTokensResponse,
)
} else {
compatibleUserTokensResponse
}
userTokensResponseStore.store(userWalletId = userWalletId, response = enrichedUserTokensResponse)
}
suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) {
val enrichedUserTokensResponse = userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = response,
)
store(userWalletId, enrichedUserTokensResponse, false)
push(userWalletId, enrichedUserTokensResponse, false)
withContext(dispatchers.default) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = true)
store(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
push(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
}
}
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.default) {
val updatedResponse = response
.applyCompatibility()
.enrichIf(userWalletId = userWalletId, condition = useEnricher)
userTokensResponseStore.store(userWalletId = userWalletId, response = updatedResponse)
}
suspend fun push(
userWalletId: UserWalletId,
response: UserTokensResponse,
useEnricher: Boolean = true,
onFailSend: () -> Unit = {},
) = withContext(dispatchers.io) {
val enrichedUserTokensResponse = if (useEnricher) {
userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = response,
) {
withContext(dispatchers.default) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher)
safeApiCall(
call = {
withContext(dispatchers.io) {
tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = enrichedResponse)
.bind()
}
},
onError = { onFailSend() },
)
} else {
response
}
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedUserTokensResponse).bind() }) {
Timber.e(it, "Unable to push user tokens for: ${userWalletId.stringValue}")
onFailSend()
}
}
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
}
private suspend fun UserTokensResponse.enrichIf(
userWalletId: UserWalletId,
condition: Boolean,
): UserTokensResponse {
if (!condition) return this
return this
.enrichByAddress(userWalletId = userWalletId)
.let {
if (accountsFeatureToggles.isFeatureEnabled) {
it.enrichByAccountId(userWalletId = userWalletId)
} else {
it
}
}
}
private suspend fun UserTokensResponse.enrichByAddress(userWalletId: UserWalletId): UserTokensResponse {
return addressesEnricher(userWalletId = userWalletId, response = this)
}
private fun UserTokensResponse.enrichByAccountId(userWalletId: UserWalletId): UserTokensResponse {
return UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, response = this)
}
}

View file

@ -1,15 +1,18 @@
package com.tangem.data.common.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.cache.etag.DefaultETagsStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.*
import com.tangem.data.common.quote.DefaultQuotesFetcher
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
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.demo.models.DemoConfig
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -42,13 +45,11 @@ internal object DataCommonModule {
@Provides
@Singleton
fun provideUserTokensEncricher(
notificationsFeatureToggles: NotificationsFeatureToggles,
walletsRepository: WalletsRepository,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
dispatchers: CoroutineDispatcherProvider,
): UserTokensResponseAddressesEnricher {
return UserTokensResponseAddressesEnricher(
notificationsFeatureToggles = notificationsFeatureToggles,
walletsRepository = walletsRepository,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
dispatchers = dispatchers,
@ -61,13 +62,15 @@ internal object DataCommonModule {
tangemTechApi: TangemTechApi,
userTokensResponseStore: UserTokensResponseStore,
dispatchers: CoroutineDispatcherProvider,
enricher: UserTokensResponseAddressesEnricher,
addressesEnricher: UserTokensResponseAddressesEnricher,
accountsFeatureToggles: AccountsFeatureToggles,
): UserTokensSaver {
return UserTokensSaver(
tangemTechApi = tangemTechApi,
userTokensResponseStore = userTokensResponseStore,
dispatchers = dispatchers,
userTokensResponseAddressesEnricher = enricher,
addressesEnricher = addressesEnricher,
accountsFeatureToggles = accountsFeatureToggles,
)
}
@ -76,4 +79,10 @@ internal object DataCommonModule {
fun provideQuotesFetcher(tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider): QuotesFetcher {
return DefaultQuotesFetcher(tangemTechApi = tangemTechApi, dispatchers = dispatchers)
}
@Provides
@Singleton
fun provideETagsStore(appPreferencesStore: AppPreferencesStore): ETagsStore {
return DefaultETagsStore(appPreferencesStore = appPreferencesStore)
}
}

View file

@ -0,0 +1,151 @@
package com.tangem.data.common.currency
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserTokensResponseAccountIdEnricherTest {
private val userWalletId = UserWalletId("011")
private val mockCryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val userTokensResponseFactory = UserTokensResponseFactory()
@Test
fun `enriches tokens with missing account ids`() {
// Arrange
val response = mockCryptoCurrencyFactory.ethereumAndStellar
.mapIndexed { index, currency ->
currency.toResponseToken(
accountId = null,
derivationPath = "m/44'/60'/$index'/0/0",
)
}
.toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = response.tokens
.mapIndexed { index, currency ->
currency.enrichWithAccountId(accountIndex = index)
}
.toResponse()
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `does not modify tokens with existing account ids`() {
// Arrange
val response = mockCryptoCurrencyFactory.ethereumAndStellar
.mapIndexed { index, currency ->
currency.toResponseToken(derivationPath = "m/44'/60'/$index'/0/0")
.enrichWithAccountId(accountIndex = index)
}
.toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = response
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `skips tokens with invalid derivation paths`() {
// Arrange
val validDerivationPath = "m/44'/60'/0'/0/0"
val invalidDerivationPath = "invalid/path"
val tokenWithInvalidPath = mockCryptoCurrencyFactory.ethereum.toResponseToken(
accountId = null,
derivationPath = invalidDerivationPath,
)
val tokenWithValidPath = mockCryptoCurrencyFactory.stellar.toResponseToken(
accountId = null,
derivationPath = validDerivationPath,
)
val response = listOf(tokenWithInvalidPath, tokenWithValidPath).toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = listOf(
tokenWithInvalidPath,
tokenWithValidPath.enrichWithAccountId(accountIndex = 0),
).toResponse()
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `skips tokens with unknown network id`() {
// Arrange
val unknownNetworkId = "unknown"
val validNetworkId = mockCryptoCurrencyFactory.ethereum.network.rawId
val tokenWithUnknownNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken(
networkId = unknownNetworkId,
derivationPath = "m/44'/60'/0'/0/0",
accountId = null,
)
val tokenWithValidNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken(
accountId = null,
networkId = validNetworkId,
derivationPath = "m/44'/60'/0'/0/0",
)
val response = listOf(tokenWithUnknownNetworkId, tokenWithValidNetworkId).toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = listOf(
tokenWithUnknownNetworkId,
tokenWithValidNetworkId.enrichWithAccountId(accountIndex = 0),
).toResponse()
Truth.assertThat(actual).isEqualTo(expected)
}
private fun CryptoCurrency.toResponseToken(
accountId: AccountId? = null,
networkId: String? = null,
derivationPath: String,
): UserTokensResponse.Token {
return userTokensResponseFactory.createResponseToken(this).copy(
networkId = networkId ?: network.rawId,
derivationPath = derivationPath,
accountId = accountId?.value,
)
}
private fun List<UserTokensResponse.Token>.toResponse(): UserTokensResponse {
return UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = this,
)
}
private fun UserTokensResponse.Token.enrichWithAccountId(accountIndex: Int): UserTokensResponse.Token {
val derivationIndex = DerivationIndex(value = accountIndex).getOrNull()!!
val accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex)
return copy(accountId = accountId.value)
}
}

View file

@ -7,7 +7,6 @@ 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.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -23,7 +22,6 @@ import org.junit.Test
class UserTokensResponseAddressesEnricherTest {
private lateinit var notificationsFeatureToggles: NotificationsFeatureToggles
private lateinit var walletsRepository: WalletsRepository
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier
@ -31,12 +29,10 @@ class UserTokensResponseAddressesEnricherTest {
@Before
fun setup() {
notificationsFeatureToggles = mockk()
walletsRepository = mockk()
multiNetworkStatusSupplier = mockk()
enricher = UserTokensResponseAddressesEnricher(
notificationsFeatureToggles = notificationsFeatureToggles,
walletsRepository = walletsRepository,
dispatchers = dispatchers,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
@ -54,7 +50,6 @@ class UserTokensResponseAddressesEnricherTest {
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
every { notificationsFeatureToggles.isNotificationsEnabled } returns false
// WHEN
val result = enricher(userWalletId, response)
@ -70,7 +65,6 @@ class UserTokensResponseAddressesEnricherTest {
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
every { notificationsFeatureToggles.isNotificationsEnabled } returns true
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns false
coEvery {
multiNetworkStatusSupplier.invoke(any())
@ -110,7 +104,6 @@ class UserTokensResponseAddressesEnricherTest {
val response = createUserTokensResponse(tokens = listOf(token))
val addresses = listOf("0x123", "0x456")
every { notificationsFeatureToggles.isNotificationsEnabled } returns true
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true
coEvery {
multiNetworkStatusSupplier.invoke(any())
@ -152,7 +145,6 @@ class UserTokensResponseAddressesEnricherTest {
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
every { notificationsFeatureToggles.isNotificationsEnabled } returns true
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true
coEvery {
multiNetworkStatusSupplier.invoke(any())

View file

@ -5,6 +5,7 @@ import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
@ -19,12 +20,16 @@ class UserTokensSaverTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true)
private val enricher: UserTokensResponseAddressesEnricher = mockk()
private val accountsFeatureToggles = mockk<AccountsFeatureToggles> {
every { this@mockk.isFeatureEnabled } returns true
}
private val userTokensSaver: UserTokensSaver = UserTokensSaver(
tangemTechApi = tangemTechApi,
userTokensResponseStore = userTokensResponseStore,
userTokensResponseAddressesEnricher = enricher,
dispatchers = TestingCoroutineDispatcherProvider(),
addressesEnricher = enricher,
accountsFeatureToggles = accountsFeatureToggles,
)
@BeforeEach

View file

@ -10,7 +10,6 @@ android {
}
dependencies {
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
@ -37,6 +36,8 @@ dependencies {
// endregion
// Feature modules
implementation(projects.features.hotWallet.api)
implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models)

View file

@ -4,14 +4,17 @@ import android.os.Build
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.navigation.email.EmailSender
import com.tangem.data.feedback.converters.BlockchainInfoConverter
import com.tangem.data.feedback.converters.CardInfoConverter
import com.tangem.data.feedback.converters.WalletMetaInfoConverter
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.models.*
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.utils.version.AppVersionProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -23,28 +26,43 @@ import java.io.File
*
* @property appLogsStore app logs store
* @property userWalletsListManager user wallets list manager
* @property useNewUserWalletsRepository flag to use new user wallets repository
* @property userWalletsListRepository user wallets repository
* @property walletManagersStore wallet managers store
* @property emailSender email sender
* @property appVersionProvider app version provider
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class DefaultFeedbackRepository(
private val appLogsStore: AppLogsStore,
private val useNewUserWalletsRepository: Boolean,
private val userWalletsListRepository: UserWalletsListRepository,
private val userWalletsListManager: UserWalletsListManager,
private val walletManagersStore: WalletManagersStore,
private val emailSender: EmailSender,
private val appVersionProvider: AppVersionProvider,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
) : FeedbackRepository {
private val blockchainsErrors = MutableStateFlow<Map<UserWalletId, BlockchainErrorInfo>>(emptyMap())
override fun getCardInfo(scanResponse: ScanResponse) = CardInfoConverter.convert(value = scanResponse)
override suspend fun getUserWalletMetaInfo(userWalletId: UserWalletId): WalletMetaInfo {
val userWallet = getUserWalletById(userWalletId)
return userWallet?.let {
WalletMetaInfoConverter.convert(it)
} ?: WalletMetaInfo(userWalletId)
}
override fun getUserWalletMetaInfo(scanResponse: ScanResponse): WalletMetaInfo {
return WalletMetaInfoConverter.convert(value = scanResponse)
}
override fun getUserWalletsInfo(userWalletId: UserWalletId?): UserWalletsInfo {
return UserWalletsInfo(
selectedUserWalletId = userWalletId?.stringValue ?: "card isn't activated",
totalUserWallets = userWalletsListManager.walletsCount,
totalUserWallets = totalUserWallets(),
)
}
@ -77,7 +95,7 @@ internal class DefaultFeedbackRepository(
}
override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) {
val userWallet = userWalletsListManager.selectedUserWalletSync ?: error("UserWallet is not selected")
val userWallet = getSelectedWalletUseCase.sync().getOrNull() ?: error("UserWallet is not selected")
blockchainsErrors.update {
it.toMutableMap().apply {
@ -106,4 +124,20 @@ internal class DefaultFeedbackRepository(
),
)
}
private suspend fun getUserWalletById(userWalletId: UserWalletId): UserWallet? {
return if (useNewUserWalletsRepository) {
userWalletsListRepository.userWalletsSync().find { it.walletId == userWalletId }
} else {
userWalletsListManager.userWalletsSync.find { it.walletId == userWalletId }
}
}
private fun totalUserWallets(): Int {
return if (useNewUserWalletsRepository) {
userWalletsListRepository.userWallets.value?.size ?: 0
} else {
userWalletsListManager.walletsCount
}
}
}

View file

@ -1,41 +0,0 @@
package com.tangem.data.feedback.converters
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.card.common.util.getBackupCardsCount
import com.tangem.domain.feedback.models.CardInfo
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.utils.converter.Converter
/**
* Converter from [ScanResponse] to [CardInfo]
*
[REDACTED_AUTHOR]
*/
internal object CardInfoConverter : Converter<ScanResponse, CardInfo> {
override fun convert(value: ScanResponse): CardInfo {
return with(value) {
CardInfo(
userWalletId = createUserWalletId(scanResponse = value),
cardId = card.cardId,
cardsCount = value.getBackupCardsCount()?.toString() ?: "0",
firmwareVersion = card.firmwareVersion.stringValue,
cardBlockchain = walletData?.blockchain,
signedHashesList = card.wallets.map {
CardInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString())
},
isImported = value.card.wallets.any(CardDTO.Wallet::isImported),
isStart2Coin = value.card.isStart2Coin,
isVisa = value.card.isVisa,
)
}
}
private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId? {
return UserWalletIdBuilder.scanResponse(scanResponse = scanResponse).build()
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.feedback.converters
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.card.common.util.getBackupCardsCount
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.utils.converter.Converter
/**
* Converter from [UserWallet] to [WalletMetaInfo]
*
[REDACTED_AUTHOR]
*/
internal object WalletMetaInfoConverter : Converter<UserWallet, WalletMetaInfo> {
override fun convert(value: UserWallet): WalletMetaInfo {
return when (value) {
is UserWallet.Cold -> {
WalletMetaInfo(
userWalletId = value.walletId,
cardId = value.scanResponse.card.cardId,
cardsCount = value.getBackupCardsCount()?.toString() ?: "0",
firmwareVersion = value.scanResponse.card.firmwareVersion.stringValue,
cardBlockchain = value.scanResponse.walletData?.blockchain,
signedHashesList = value.scanResponse.card.wallets.map {
WalletMetaInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString())
},
isImported = value.scanResponse.card.wallets.any(CardDTO.Wallet::isImported),
isStart2Coin = value.scanResponse.card.isStart2Coin,
isVisa = value.scanResponse.card.isVisa,
)
}
is UserWallet.Hot -> {
WalletMetaInfo(
userWalletId = value.walletId,
hotWalletIsBackedUp = value.backedUp,
)
}
}
}
fun convert(value: ScanResponse): WalletMetaInfo {
return WalletMetaInfo(
userWalletId = createUserWalletId(value),
cardId = value.card.cardId,
cardsCount = value.getBackupCardsCount()?.toString() ?: "0",
firmwareVersion = value.card.firmwareVersion.stringValue,
cardBlockchain = value.walletData?.blockchain,
signedHashesList = value.card.wallets.map {
WalletMetaInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString())
},
isImported = value.card.wallets.any(CardDTO.Wallet::isImported),
isStart2Coin = value.card.isStart2Coin,
isVisa = value.card.isVisa,
)
}
private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId? {
return UserWalletIdBuilder.scanResponse(scanResponse = scanResponse).build()
}
}

View file

@ -6,9 +6,12 @@ import com.tangem.data.feedback.DefaultFeedbackFeatureToggles
import com.tangem.data.feedback.DefaultFeedbackRepository
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.version.AppVersionProvider
import dagger.Module
import dagger.Provides
@ -25,9 +28,12 @@ internal object FeedbackModule {
fun provideFeedbackRepository(
appLogsStore: AppLogsStore,
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
walletManagersStore: WalletManagersStore,
emailSender: EmailSender,
appVersionProvider: AppVersionProvider,
getSelectedWalletUseCase: GetSelectedWalletUseCase,
): FeedbackRepository {
return DefaultFeedbackRepository(
appLogsStore = appLogsStore,
@ -35,6 +41,9 @@ internal object FeedbackModule {
walletManagersStore = walletManagersStore,
emailSender = emailSender,
appVersionProvider = appVersionProvider,
userWalletsListRepository = userWalletsListRepository,
useNewUserWalletsRepository = hotWalletFeatureToggles.isHotWalletEnabled,
getSelectedWalletUseCase = getSelectedWalletUseCase,
)
}

View file

@ -261,6 +261,48 @@ internal class DefaultOnrampRepository(
storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await())
}
override suspend fun hasMercuryoSepaMethod(
userWallet: UserWallet,
currency: OnrampCurrency,
country: OnrampCountry,
cryptoCurrency: CryptoCurrency,
): Boolean {
return withContext(dispatchers.io) {
val onrampPairs =
safeApiCall(
call = {
onrampApi.getPairs(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
body = OnrampPairsRequest(
fromCurrencyCode = currency.code,
countryCode = country.code,
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
),
),
),
).bind()
},
onError = {
Timber.w(it, "Unable to fetch onramp pairs")
throw it
},
)
val mercuryoProvider = onrampPairs.map { it.providers }.flatten()
.find { it.providerId == MERCURYO_PROVIDER_ID }
val hasSepaMethod = mercuryoProvider?.paymentMethods?.any { it == SEPA_METHOD_ID } ?: false
hasSepaMethod
}
}
override suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) =
withContext(dispatchers.io) {
val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) {
@ -554,5 +596,8 @@ internal class DefaultOnrampRepository(
const val PROVIDER_THEME_LIGHT = "light"
const val REDIRECT_URL = "https://tangem.com/onramp"
const val SEPA_METHOD_ID = "sepa"
const val MERCURYO_PROVIDER_ID = "mercuryo"
}
}

View file

@ -38,24 +38,19 @@ internal class DefaultPromoRepository(
key = PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name),
default = true,
).map { shouldShow ->
if (promoId == PromoId.Referral) {
runCatching {
when (promoId) {
PromoId.Referral -> runCatching {
!referralRepository.isReferralParticipant(userWalletId) && shouldShow
}.getOrDefault(false)
} else {
shouldShow
PromoId.Sepa -> shouldShow
}
}
}
override fun isReadyToShowTokenPromo(promoId: PromoId): Flow<Boolean> {
return if (promoId == PromoId.Referral) {
flowOf(false)
} else {
appPreferencesStore.get(
PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name),
default = false,
)
return when (promoId) {
PromoId.Referral -> flowOf(false)
PromoId.Sepa -> flowOf(false)
}
}

View file

@ -29,15 +29,15 @@ internal class DefaultSettingsRepository(
private val userCountryFlow = MutableStateFlow<UserCountry?>(value = null)
override suspend fun shouldShowSaveUserWalletScreen(): Boolean {
override suspend fun shouldShowAskBiometry(): Boolean {
return appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY,
key = PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY,
default = true,
)
}
override suspend fun setShouldShowSaveUserWalletScreen(value: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, value = value)
override suspend fun setShouldShowAskBiometry(value: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY, value = value)
}
override suspend fun isWalletScrollPreviewEnabled(): Boolean {

View file

@ -27,6 +27,7 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
/** Project - Utils */
implementation(projects.core.utils)

View file

@ -0,0 +1,45 @@
package com.tangem.data.pay.datasource
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.common.CompletionResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject
internal class DefaultTangemPayAuthDataSource @Inject constructor(
private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource,
private val tangemSdkManager: TangemSdkManager,
) : TangemPayAuthDataSource {
override suspend fun generateNewAuthHeader(address: String, cardId: String): Either<Throwable, String> = either {
val challenge = visaAuthRemoteDataSource
.getCustomerWalletAuthChallenge(address)
.mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") }
.bind()
val signed = tangemSdkManager.visaCustomerWalletApprove(
VisaDataForApprove(
customerWalletCardId = cardId,
targetAddress = address,
dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge),
),
).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind()
visaAuthRemoteDataSource.getTokenWithCustomerWallet(
sessionId = challenge.session.sessionId,
signature = signed.signature,
nonce = signed.dataToSign.hashToSign,
)
.mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") }
.bind()
}
}
private fun <T> CompletionResult<T>.toEither(map: (Throwable) -> Throwable) = when (this) {
is CompletionResult.Success -> Either.Right(data)
is CompletionResult.Failure -> Either.Left(map(error))
}

View file

@ -1,7 +1,9 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.data.pay.usecase.DefaultKycStartInfoUseCase
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.usecase.KycStartInfoUseCase
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -14,5 +16,9 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindKycRepositoryFactory(factory: DefaultKycRepository.Factory): KycRepository.Factory
fun bindKycRepository(repository: DefaultKycRepository): KycRepository
@Binds
@Singleton
fun bindKycStartInfoUseCase(useCase: DefaultKycStartInfoUseCase): KycStartInfoUseCase
}

View file

@ -1,58 +1,47 @@
package com.tangem.data.pay
package com.tangem.data.pay.repository
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.common.map
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.KycStartInfo
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.sdk.api.TangemSdkManager
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import javax.inject.Inject
class DefaultKycRepository @AssistedInject constructor(
class DefaultKycRepository @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val tangemPayApi: TangemPayApi,
private val visaAuthRepository: VisaAuthRepository,
private val tangemSdkManager: TangemSdkManager,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayStorage: TangemPayStorage,
) : KycRepository {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
override suspend fun getKycStartInfo(address: String, cardId: String): Either<UniversalError, KycStartInfo> {
var authHeader = ""
visaAuthRepository.getCustomerWalletAuthChallenge(address).getOrNull()?.let { result ->
tangemSdkManager.visaCustomerWalletApprove(
VisaDataForApprove(
customerWalletCardId = cardId,
targetAddress = address,
dataToSign = VisaDataToSignByCustomerWallet(hashToSign = result.challenge),
),
).map { signResult ->
visaAuthRepository.getTokenWithCustomerWallet(
sessionId = result.session.sessionId,
signature = signResult.signature,
nonce = signResult.dataToSign.hashToSign,
).getOrNull()?.let { authHeader = it }
}
}
val authHeader = authDataSource.generateNewAuthHeader(address, cardId)
.getOrNull()
.takeIf { !it.isNullOrEmpty() }
?: return Either.Left(VisaApiError.UnknownWithoutCode)
tangemPayStorage.store(authHeader)
return getKycInfo(authHeader)
}
override suspend fun getKycStartInfo(authHeader: String): Either<UniversalError, KycStartInfo> {
return getKycInfo(authHeader)
}
private suspend fun getKycInfo(authHeader: String): Either<UniversalError, KycStartInfo> {
return request {
authHeader.ifEmpty { error("Cannot get auth header for KYC") }
tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result
}.map {
KycStartInfo(
token = it.token,
locale = it.locale,
)
KycStartInfo(token = it.token, locale = it.locale)
}
}
@ -75,9 +64,4 @@ class DefaultKycRepository @AssistedInject constructor(
return Either.Left(VisaApiError.UnknownWithoutCode)
}
}
@AssistedFactory
interface Factory : KycRepository.Factory {
override fun create(): DefaultKycRepository
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.core.error.UniversalError
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.KycStartInfo
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.usecase.KycStartInfoUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
/**
* For TangemPay Customer Wallet auth we are using polygon address
*/
private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token"
internal class DefaultKycStartInfoUseCase @Inject constructor(
private val userWalletsRepository: UserWalletsListRepository,
private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val kycRepository: KycRepository,
) : KycStartInfoUseCase {
override suspend fun invoke(): Either<UniversalError, KycStartInfo> = either {
val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold
?: raise(VisaApiError.UnknownWithoutCode)
val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE))
.getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode)
kycRepository.getKycStartInfo(address, wallet.cardId).bind()
}
}

View file

@ -18,7 +18,7 @@ import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -32,7 +32,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
private val visaApi: TangemPayApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val visaAuthRepository: VisaAuthRepository,
private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource,
private val visaLibLoader: VisaLibLoader,
private val apiConfigsManager: ApiConfigsManager,
) : VisaActivationRepository {
@ -194,7 +194,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
}
val authTokens = visaAuthTokenStorage.get(visaCardId.cardId) ?: error("Auth tokens are not stored")
val newTokens = visaAuthRepository.refreshAccessTokens(authTokens.refreshToken).getOrElse {
val newTokens = visaAuthRemoteDataSource.refreshAccessTokens(authTokens.refreshToken).getOrElse {
return Either.Left(VisaApiError.RefreshTokenExpired)
}

View file

@ -13,17 +13,16 @@ import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
@Suppress("UnusedPrivateMember")
internal class DefaultVisaAuthRepository @Inject constructor(
internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
private val visaAuthApi: TangemPayApi,
private val dispatchers: CoroutineDispatcherProvider,
) : VisaAuthRepository {
) : VisaAuthRemoteDataSource {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)

View file

@ -1,10 +1,12 @@
package com.tangem.data.visa.di
import com.tangem.data.pay.datasource.DefaultTangemPayAuthDataSource
import com.tangem.data.visa.DefaultVisaActivationRepository
import com.tangem.data.visa.DefaultVisaAuthRepository
import com.tangem.data.visa.DefaultVisaAuthRemoteDataSource
import com.tangem.data.visa.MockVisaRepository
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.repository.VisaRepository
import dagger.Binds
import dagger.Module
@ -18,7 +20,7 @@ internal interface VisaDataModule {
@Binds
@Singleton
fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository
fun bindVisaAuthRemoteDataSource(repository: DefaultVisaAuthRemoteDataSource): VisaAuthRemoteDataSource
@Binds
@Singleton
@ -39,4 +41,8 @@ internal interface VisaDataModule {
// Mocked
@Binds
fun bindVisaRepository(repository: MockVisaRepository): VisaRepository
@Binds
@Singleton
fun bindTangemPayAuthDataSource(repository: DefaultTangemPayAuthDataSource): TangemPayAuthDataSource
}

View file

@ -21,12 +21,9 @@ import com.tangem.datasource.di.SdkMoshi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.disconnect.WcDisconnectUseCase
@ -90,7 +87,6 @@ internal object WalletConnectDataModule {
fun defaultWcSessionsManager(
store: WalletConnectStore,
dispatchers: CoroutineDispatcherProvider,
legacyStore: WalletConnectSessionsRepository,
getWallets: GetWalletsUseCase,
wcNetworksConverter: WcNetworksConverter,
analytics: AnalyticsEventHandler,
@ -99,7 +95,6 @@ internal object WalletConnectDataModule {
return DefaultWcSessionsManager(
store = store,
dispatchers = dispatchers,
legacyStore = legacyStore,
getWallets = getWallets,
wcNetworksConverter = wcNetworksConverter,
analytics = analytics,
@ -177,15 +172,11 @@ internal object WalletConnectDataModule {
fun wcNetworksConverter(
namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>,
walletManagersFacade: WalletManagersFacade,
currenciesRepository: CurrenciesRepository,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
tokensFeatureToggles: TokensFeatureToggles,
): WcNetworksConverter = WcNetworksConverter(
namespaceConverters = namespaceConverters,
walletManagersFacade = walletManagersFacade,
currenciesRepository = currenciesRepository,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
tokensFeatureToggles = tokensFeatureToggles,
)
@Provides
@ -193,15 +184,11 @@ internal object WalletConnectDataModule {
fun associateNetworksDelegate(
namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>,
getWallets: GetWalletsUseCase,
currenciesRepository: CurrenciesRepository,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
tokensFeatureToggles: TokensFeatureToggles,
): AssociateNetworksDelegate = AssociateNetworksDelegate(
namespaceConverters = namespaceConverters,
getWallets = getWallets,
currenciesRepository = currenciesRepository,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
tokensFeatureToggles = tokensFeatureToggles,
)
@Provides

View file

@ -10,8 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
@ -19,9 +17,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase
internal class AssociateNetworksDelegate(
private val namespaceConverters: Set<WcNamespaceConverter>,
private val getWallets: GetWalletsUseCase,
private val currenciesRepository: CurrenciesRepository,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val tokensFeatureToggles: TokensFeatureToggles,
) {
@Throws(WcPairError.UnsupportedBlockchains::class)
@ -96,14 +92,10 @@ internal class AssociateNetworksDelegate(
}
private suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
} else {
currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId)
}
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.filterIsInstance<CryptoCurrency.Coin>()
.map(CryptoCurrency.Coin::network)
// flatten all derivation

View file

@ -3,7 +3,6 @@ package com.tangem.data.walletconnect.sessions
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -16,7 +15,6 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionDTO
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -30,7 +28,6 @@ import kotlin.coroutines.resume
@Suppress("LongParameterList")
internal class DefaultWcSessionsManager(
private val store: WalletConnectStore,
private val legacyStore: WalletConnectSessionsRepository,
private val getWallets: GetWalletsUseCase,
private val dispatchers: CoroutineDispatcherProvider,
private val wcNetworksConverter: WcNetworksConverter,
@ -39,18 +36,12 @@ internal class DefaultWcSessionsManager(
) : WcSessionsManager, WcSdkObserver {
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
private val oneTimeMigration = MutableStateFlow(true)
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
.transform { pair ->
val (wallets, inStore) = pair
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
if (oneTimeMigration.value) {
oneTimeMigration.value = false
val someMigrated = migrateLegacyStore(inStore, inSdk, wallets)
if (someMigrated) return@transform // ignore emit, wait next one
}
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
val someRemove = removeUnknownSessions(inStore, inSdk, associatedSessions)
if (someRemove) return@transform // ignore emit, wait next one
@ -60,7 +51,6 @@ internal class DefaultWcSessionsManager(
.flowOn(dispatchers.io)
override fun onWcSdkInit() {
oneTimeMigration.value = true
listenOnSessionDelete()
extendSessions()
}
@ -96,39 +86,6 @@ internal class DefaultWcSessionsManager(
onSessionDelete.trySend(sessionDelete)
}
private suspend fun migrateLegacyStore(
inNewStore: Set<WcSessionDTO>,
inSdk: List<Wallet.Model.Session>,
wallets: List<UserWallet>,
): Boolean {
val walletIds = wallets.map { wallet -> wallet.walletId }
val inLegacyStore = walletIds
.map { walletId ->
flow {
emit(
legacyStore.loadSessions(walletId.stringValue).mapNotNull { legacySession ->
val url =
inSdk.find { it.topic == legacySession.topic }?.metaData?.url ?: return@mapNotNull null
WcSessionDTO(
topic = legacySession.topic,
walletId = walletId,
url = url,
securityStatus = CheckDAppResult.FAILED_TO_VERIFY,
)
},
)
}
}
.merge()
.reduce { accumulator, value -> accumulator.plus(value) }
// migrate only active legacySessions
.filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } }
val mustSaveInNewStore = inLegacyStore.subtract(inNewStore)
if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore)
return mustSaveInNewStore.isNotEmpty()
}
private suspend fun associate(
inSdk: List<Wallet.Model.Session>,
inStore: Set<WcSessionDTO>,

View file

@ -9,8 +9,6 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionApprove
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
@ -20,9 +18,7 @@ import javax.inject.Inject
internal class WcNetworksConverter @Inject constructor(
private val namespaceConverters: Set<WcNamespaceConverter>,
private val walletManagersFacade: WalletManagersFacade,
private val currenciesRepository: CurrenciesRepository,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val tokensFeatureToggles: TokensFeatureToggles,
) {
fun createNetwork(chainId: String, wallet: UserWallet): Network? {
@ -101,12 +97,10 @@ internal class WcNetworksConverter @Inject constructor(
}
private suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
).orEmpty()
} else {
currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId)
}.filterIsInstance<CryptoCurrency.Coin>().map(CryptoCurrency.Coin::network)
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.filterIsInstance<CryptoCurrency.Coin>().map(CryptoCurrency.Coin::network)
}
}

View file

@ -37,6 +37,7 @@ import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.collections.mutableSetOf
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@ -50,6 +51,9 @@ internal class DefaultWalletsRepository(
private val authProvider: AuthProvider,
) : WalletsRepository {
private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> =
MutableStateFlow(mutableSetOf<UserWalletId>())
override suspend fun shouldSaveUserWalletsSync(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
}
@ -333,6 +337,16 @@ internal class DefaultWalletsRepository(
}
}
override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow<Boolean> {
return upgradeWalletNotificationDisabled.map {
it.contains(userWalletId)
}
}
override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) {
upgradeWalletNotificationDisabled.update { it.plus(userWalletId) }
}
override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) {
tangemTechApi.updateWallet(
walletId = walletId,

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.hot.sdk.model.DeriveWalletRequest
import com.tangem.operations.derivation.ExtendedPublicKeysMap

View file

@ -3,30 +3,72 @@ package com.tangem.data.wallets.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.copy
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.exception.WrongPasswordException
import com.tangem.hot.sdk.model.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlin.collections.set
class HotWalletAccessor @Inject constructor(
class DefaultHotWalletAccessor @Inject constructor(
private val tangemHotSdk: TangemHotSdk,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
private val walletsRepository: WalletsRepository,
) {
dispatchers: CoroutineDispatcherProvider,
) : HotWalletAccessor {
suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
private var contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap()
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign)
}
suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request)
override suspend fun derivePublicKeys(
hotWalletId: HotWalletId,
request: DeriveWalletRequest,
): DerivedPublicKeyResponse = hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request)
}
override suspend fun exportSeedPhrase(hotWalletId: HotWalletId): SeedPhrasePrivateInfo {
val unlockHotWallet = contextualUnlockHotWallet[hotWalletId] ?: hotSdkRequest(hotWalletId) { it }
return tangemHotSdk.exportMnemonic(unlockHotWallet = unlockHotWallet)
}
override suspend fun unlockContextual(hotWalletId: HotWalletId): UnlockHotWallet = hotSdkRequest(hotWalletId) {
tangemHotSdk.getContextUnlock(it).also { unlockHotWallet ->
contextualUnlockHotWallet[hotWalletId] = unlockHotWallet
}
}
override fun getContextualUnlock(hotWalletId: HotWalletId): UnlockHotWallet? =
contextualUnlockHotWallet[hotWalletId]
override fun clearContextualUnlock(hotWalletId: HotWalletId) {
contextualUnlockHotWallet[hotWalletId] = null
scope.launch {
tangemHotSdk.clearUnlockContext(hotWalletId)
}
}
override fun clearAllContextualUnlock() {
val hotWalletsIds = contextualUnlockHotWallet.keys.toList()
contextualUnlockHotWallet.clear()
scope.launch {
hotWalletsIds.forEach { tangemHotSdk.clearUnlockContext(it) }
}
}
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
val isAccessCodeRequired = walletsRepository.requireAccessCode()

View file

@ -6,6 +6,7 @@ import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.map
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.operations.sign.SignData
import dagger.assisted.Assisted