Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-04 12:47:51 +03:00
commit ff24b00af4
404 changed files with 12634 additions and 3818 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,44 @@
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,
@Assisted val version: Int,
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(
version = version,
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, version: Int): 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

@ -1,90 +1,150 @@
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,
version = version,
)
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,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,130 @@
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,
version = 0,
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,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, version = version)
} 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, version)
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(), 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

@ -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

@ -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

@ -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,6 +1,6 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.domain.pay.repository.KycRepository
import dagger.Binds
import dagger.Module

View file

@ -1,58 +1,48 @@
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
class DefaultKycRepository @AssistedInject 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)
}
}

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,8 +21,6 @@ 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
@ -177,15 +175,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 +187,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

@ -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,7 +3,7 @@ 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
@ -11,22 +11,24 @@ import com.tangem.hot.sdk.exception.WrongPasswordException
import com.tangem.hot.sdk.model.*
import javax.inject.Inject
class HotWalletAccessor @Inject constructor(
class DefaultHotWalletAccessor @Inject constructor(
private val tangemHotSdk: TangemHotSdk,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
private val walletsRepository: WalletsRepository,
) {
) : HotWalletAccessor {
suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
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)
}
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