Updated on 2026-08-14
This commit is contained in:
commit
47dc1c38df
762 changed files with 23960 additions and 10924 deletions
|
|
@ -26,14 +26,13 @@ import com.tangem.datasource.utils.setTypes
|
|||
import com.tangem.domain.account.repository.AccountsCRUDRepository
|
||||
import com.tangem.domain.account.repository.AccountsExpandedRepository
|
||||
import com.tangem.domain.account.tokens.MainAccountTokensMigration
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -69,7 +68,7 @@ internal object AccountDataModule {
|
|||
fun provideAccountsExpandedRepository(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
appScope: AppCoroutineScope,
|
||||
): AccountsExpandedRepository {
|
||||
val store = DataStoreFactory.create<Map<String, Set<AccountsExpandedDTO>>>(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
|
|
@ -78,7 +77,7 @@ internal object AccountDataModule {
|
|||
defaultValue = emptyMap(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "account_expanded_store") },
|
||||
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
return DefaultAccountsExpandedRepository(
|
||||
|
|
@ -107,14 +106,14 @@ internal object AccountDataModule {
|
|||
userTokensSaver: UserTokensSaver,
|
||||
accountTokenMigrationStore: AccountTokenMigrationStore,
|
||||
eTagsStore: ETagsStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
appScope: AppCoroutineScope,
|
||||
): DefaultMainAccountTokensMigration {
|
||||
return DefaultMainAccountTokensMigration(
|
||||
accountsResponseStoreFactory = accountsResponseStoreFactory,
|
||||
accountTokenMigrationStore = accountTokenMigrationStore,
|
||||
userTokensSaver = userTokensSaver,
|
||||
eTagsStore = eTagsStore,
|
||||
dispatchers = dispatchers,
|
||||
coroutineScope = appScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.account.fetcher
|
||||
|
||||
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult
|
||||
import com.tangem.data.account.store.LegacyUserTokensResponseStore
|
||||
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
|
||||
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
|
|
@ -14,7 +15,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
|||
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
|
||||
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -35,7 +35,7 @@ import javax.inject.Singleton
|
|||
@Singleton
|
||||
internal class FetchWalletAccountsErrorHandler @Inject constructor(
|
||||
private val userTokensSaver: UserTokensSaver,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val userTokensResponseStore: LegacyUserTokensResponseStore,
|
||||
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
|
||||
private val walletServerBinder: WalletServerBinder,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
package com.tangem.data.account.producer
|
||||
|
||||
import arrow.core.Option
|
||||
import arrow.core.some
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.getSyncStrict
|
||||
import com.tangem.domain.core.flow.FlowProducerTools
|
||||
import com.tangem.domain.models.account.DerivationIndex
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiWalletCryptoCurrenciesProducer]
|
||||
*
|
||||
* @property params params
|
||||
* @property flowProducerTools tools for producing flows
|
||||
* @property userWalletsListRepository repository for getting user wallets
|
||||
* @property userTokensResponseStore store of `UserTokensResponse`
|
||||
* @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse`
|
||||
* @property dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constructor(
|
||||
@Assisted val params: MultiWalletCryptoCurrenciesProducer.Params,
|
||||
override val flowProducerTools: FlowProducerTools,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiWalletCryptoCurrenciesProducer {
|
||||
|
||||
override val fallback: Option<Set<CryptoCurrency>> = emptySet<CryptoCurrency>().some()
|
||||
|
||||
override fun produce(): Flow<Set<CryptoCurrency>> {
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId)
|
||||
|
||||
if (!userWallet.isMultiCurrency) {
|
||||
error("${this::class.simpleName ?: this::class.toString()} supports only multi-currency wallet")
|
||||
}
|
||||
|
||||
return userTokensResponseStore.get(userWalletId = params.userWalletId)
|
||||
.distinctUntilChanged()
|
||||
.map { response ->
|
||||
if (response == null) return@map emptySet()
|
||||
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = response,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
).toSet()
|
||||
}
|
||||
.onEmpty { emit(emptySet()) }
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : MultiWalletCryptoCurrenciesProducer.Factory {
|
||||
override fun create(
|
||||
params: MultiWalletCryptoCurrenciesProducer.Params,
|
||||
): DefaultMultiWalletCryptoCurrenciesProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ package com.tangem.data.account.producer
|
|||
import com.tangem.data.account.converter.AccountListConverter
|
||||
import com.tangem.data.account.store.AccountsResponseStore
|
||||
import com.tangem.data.account.store.AccountsResponseStoreFactory
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
|
|
|
|||
|
|
@ -10,11 +10,9 @@ 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.utils.coroutines.AppCoroutineScope
|
||||
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
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -35,7 +33,7 @@ typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
|
|||
internal class AccountsResponseStoreFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@NetworkMoshi private val moshi: Moshi,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val appScope: AppCoroutineScope,
|
||||
) {
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
|
|
@ -53,7 +51,7 @@ internal class AccountsResponseStoreFactory @Inject constructor(
|
|||
DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter),
|
||||
produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") },
|
||||
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
|
||||
scope = appScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.data.account.store
|
||||
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Legacy store for user tokens
|
||||
*
|
||||
* @property appPreferencesStore app preferences store
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class LegacyUserTokensResponseStore @Inject constructor(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) {
|
||||
|
||||
suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse? {
|
||||
return appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
|
||||
key = createPreferencesKey(userWalletId = userWalletId.stringValue),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun clear(userWalletId: UserWalletId) {
|
||||
appPreferencesStore.updateData { preferences ->
|
||||
val key = createPreferencesKey(userWalletId = userWalletId.stringValue)
|
||||
|
||||
preferences.toMutablePreferences().apply { remove(key) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPreferencesKey(userWalletId: String) = stringPreferencesKey(name = "user_tokens_$userWalletId")
|
||||
}
|
||||
|
|
@ -20,12 +20,10 @@ import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
|
|||
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
|
||||
import com.tangem.datasource.utils.getSyncOrNull
|
||||
import com.tangem.domain.account.tokens.MainAccountTokensMigration
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.account.DerivationIndex
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
|
@ -43,11 +41,9 @@ internal class DefaultMainAccountTokensMigration(
|
|||
private val accountTokenMigrationStore: AccountTokenMigrationStore,
|
||||
private val userTokensSaver: UserTokensSaver,
|
||||
private val eTagsStore: ETagsStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val coroutineScope: AppCoroutineScope,
|
||||
) : MainAccountTokensMigration {
|
||||
|
||||
private val coroutineScope = CoroutineScope(dispatchers.default + SupervisorJob())
|
||||
|
||||
internal suspend fun migrate(userWalletId: UserWalletId): Either<Throwable, GetWalletAccountsResponse> = either {
|
||||
val store = accountsResponseStoreFactory.create(userWalletId)
|
||||
val response = store.getSyncOrNull()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher
|
|||
|
||||
import com.tangem.data.account.converter.createGetWalletAccountsResponse
|
||||
import com.tangem.data.account.converter.createWalletAccountDTO
|
||||
import com.tangem.data.account.store.LegacyUserTokensResponseStore
|
||||
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.data.common.wallet.WalletServerBinder
|
||||
|
|
@ -14,7 +15,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
|||
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
|
||||
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
|
|
@ -34,7 +34,7 @@ class FetchWalletAccountsErrorHandlerTest {
|
|||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val walletServerBinder: WalletServerBinder = mockk()
|
||||
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
|
||||
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
|
||||
private val userTokensResponseStore: LegacyUserTokensResponseStore = mockk(relaxUnitFun = true)
|
||||
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
|
||||
|
||||
private val handler = FetchWalletAccountsErrorHandler(
|
||||
|
|
|
|||
|
|
@ -1,378 +0,0 @@
|
|||
package com.tangem.data.account.producer
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.domain.card.MockScanResponseFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.card.configs.GenericCardConfig
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.core.flow.FlowProducerTools
|
||||
import com.tangem.domain.models.account.DerivationIndex
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
|
||||
|
||||
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
||||
|
||||
private val params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWallet.walletId)
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
|
||||
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
|
||||
private val flowProducerTools: FlowProducerTools = mockk()
|
||||
|
||||
private val producer = DefaultMultiWalletCryptoCurrenciesProducer(
|
||||
params = params,
|
||||
flowProducerTools = flowProducerTools,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(userWalletsListRepository, userTokensResponseStore, responseCryptoCurrenciesFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow is mapped for user wallet id from params`() = runTest {
|
||||
// Arrange
|
||||
val userTokensResponseFlow = flowOf<UserTokensResponse?>(null)
|
||||
|
||||
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
|
||||
|
||||
// Act
|
||||
val actual = producer.produce().let(::getEmittedValues)
|
||||
|
||||
// Assert
|
||||
val expected = emptySet<CryptoCurrency>()
|
||||
|
||||
Truth.assertThat(actual.size).isEqualTo(1)
|
||||
Truth.assertThat(actual.first()).isEqualTo(expected)
|
||||
|
||||
verifyOrder {
|
||||
userWalletsListRepository.userWallets
|
||||
userTokensResponseStore.get(params.userWalletId)
|
||||
}
|
||||
|
||||
verify(inverse = true) {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow will updated if UserTokensResponse is updated`() = runTest {
|
||||
// Arrange
|
||||
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
|
||||
|
||||
val userTokensResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.TOKEN,
|
||||
sort = UserTokensResponse.SortType.MARKETCAP,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
val cryptoCurrencies = emptySet<CryptoCurrency>()
|
||||
|
||||
val updatedUserTokensResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.TOKEN,
|
||||
sort = UserTokensResponse.SortType.MARKETCAP,
|
||||
tokens = listOf(
|
||||
UserTokensResponse.Token(
|
||||
id = null,
|
||||
networkId = "bitcoin",
|
||||
derivationPath = null,
|
||||
name = "Bitcoin",
|
||||
symbol = "BTC",
|
||||
decimals = 8,
|
||||
contractAddress = null,
|
||||
addresses = listOf(),
|
||||
),
|
||||
),
|
||||
)
|
||||
val updatedCryptoCurrencies = setOf(
|
||||
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
|
||||
)
|
||||
|
||||
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
|
||||
|
||||
every {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = userTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
} returns cryptoCurrencies.toList()
|
||||
|
||||
every {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = updatedUserTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
} returns updatedCryptoCurrencies.toList()
|
||||
|
||||
val producerFlow = producer.produce()
|
||||
|
||||
// Act 1 (first emit)
|
||||
userTokensResponseFlow.emit(userTokensResponse)
|
||||
|
||||
val actual1 = getEmittedValues(flow = producerFlow)
|
||||
|
||||
// Assert
|
||||
val expected1 = cryptoCurrencies
|
||||
|
||||
Truth.assertThat(actual1.size).isEqualTo(1)
|
||||
Truth.assertThat(actual1.first()).isEqualTo(expected1)
|
||||
|
||||
verifyOrder {
|
||||
userWalletsListRepository.userWallets
|
||||
userTokensResponseStore.get(params.userWalletId)
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = userTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
}
|
||||
|
||||
// Act 2 (second emit)
|
||||
userTokensResponseFlow.emit(updatedUserTokensResponse)
|
||||
|
||||
val actual2 = getEmittedValues(flow = producerFlow)
|
||||
|
||||
// Assert
|
||||
val expected2 = listOf(cryptoCurrencies, updatedCryptoCurrencies)
|
||||
|
||||
Truth.assertThat(actual2.size).isEqualTo(2)
|
||||
Truth.assertThat(actual2).isEqualTo(expected2)
|
||||
|
||||
verifyOrder {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = updatedUserTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow is filtered the same status`() = runTest {
|
||||
// Arrange
|
||||
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
|
||||
|
||||
val userTokensResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.TOKEN,
|
||||
sort = UserTokensResponse.SortType.MARKETCAP,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val cryptoCurrencies = emptySet<CryptoCurrency>()
|
||||
|
||||
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
|
||||
|
||||
every {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = userTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
} returns cryptoCurrencies.toList()
|
||||
|
||||
val producerFlow = producer.produce()
|
||||
|
||||
// Act 1 (first emit)
|
||||
userTokensResponseFlow.emit(userTokensResponse)
|
||||
|
||||
val actual1 = getEmittedValues(flow = producerFlow)
|
||||
|
||||
// Assert
|
||||
val expected1 = cryptoCurrencies
|
||||
|
||||
Truth.assertThat(actual1.size).isEqualTo(1)
|
||||
Truth.assertThat(actual1.first()).isEqualTo(expected1)
|
||||
|
||||
verifyOrder {
|
||||
userWalletsListRepository.userWallets
|
||||
userTokensResponseStore.get(params.userWalletId)
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = userTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
}
|
||||
|
||||
// Act 2 (second emit)
|
||||
userTokensResponseFlow.emit(userTokensResponse)
|
||||
|
||||
val actual2 = getEmittedValues(flow = producerFlow)
|
||||
|
||||
// Assert
|
||||
val expected2 = expected1
|
||||
Truth.assertThat(actual2.size).isEqualTo(1)
|
||||
Truth.assertThat(actual2.first()).isEqualTo(expected2)
|
||||
}
|
||||
|
||||
@Disabled
|
||||
@Test
|
||||
fun `flow throws exception`() = runTest {
|
||||
// Arrange
|
||||
val exception = IllegalStateException()
|
||||
|
||||
val userTokensResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.TOKEN,
|
||||
sort = UserTokensResponse.SortType.MARKETCAP,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val cryptoCurrencies = emptySet<CryptoCurrency>()
|
||||
|
||||
val innerFlow = MutableStateFlow(value = false)
|
||||
val userTokensResponseFlow = flow {
|
||||
if (innerFlow.value) {
|
||||
emit(userTokensResponse)
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.buffer(capacity = 5)
|
||||
|
||||
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
|
||||
|
||||
every {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = userTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
} returns cryptoCurrencies.toList()
|
||||
|
||||
val producerFlow = producer.produceWithFallback()
|
||||
|
||||
// Act 1 (fallback)
|
||||
val actual1 = getEmittedValues(flow = producerFlow)
|
||||
|
||||
// Assert
|
||||
val expected1 = producer.fallback.getOrNull()
|
||||
Truth.assertThat(actual1.size).isEqualTo(1)
|
||||
Truth.assertThat(actual1.first()).isEqualTo(expected1)
|
||||
|
||||
verifyOrder {
|
||||
userWalletsListRepository.userWallets
|
||||
userTokensResponseStore.get(params.userWalletId)
|
||||
}
|
||||
|
||||
// Act 2 (emit)
|
||||
innerFlow.emit(value = true)
|
||||
val actual2 = getEmittedValues(flow = producerFlow)
|
||||
|
||||
// Assert
|
||||
val expected2 = cryptoCurrencies
|
||||
Truth.assertThat(actual2.size).isEqualTo(1)
|
||||
Truth.assertThat(actual2.first()).isEqualTo(expected2)
|
||||
|
||||
verifyOrder {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = userTokensResponse,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow is empty if store returns empty flow`() = runTest {
|
||||
// Arrange
|
||||
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
every { userTokensResponseStore.get(params.userWalletId) } returns emptyFlow()
|
||||
|
||||
// Act
|
||||
val actual = producer.produce().let(::getEmittedValues)
|
||||
|
||||
// Assert
|
||||
val expected = producer.fallback.getOrNull()
|
||||
Truth.assertThat(actual.size).isEqualTo(1)
|
||||
Truth.assertThat(actual.first()).isEqualTo(expected)
|
||||
|
||||
verifyOrder {
|
||||
userWalletsListRepository.userWallets
|
||||
userTokensResponseStore.get(params.userWalletId)
|
||||
}
|
||||
|
||||
verify(inverse = true) {
|
||||
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `produce throws exception if UserWallet isn't multi-currency wallet`() = runTest {
|
||||
// Arrange
|
||||
val mockUserWallet = mockk<UserWallet> {
|
||||
every { walletId } returns userWallet.walletId
|
||||
every { isMultiCurrency } returns false
|
||||
}
|
||||
|
||||
val userWalletsFlow = MutableStateFlow(listOf(mockUserWallet))
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
|
||||
// Act
|
||||
val actual = runCatching { producer.produce() }.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = IllegalStateException(
|
||||
"${DefaultMultiWalletCryptoCurrenciesProducer::class.simpleName} supports only multi-currency wallet",
|
||||
)
|
||||
|
||||
Truth.assertThat(actual).isInstanceOf(expected::class.java)
|
||||
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
|
||||
|
||||
verifyOrder { userWalletsListRepository.userWallets }
|
||||
|
||||
verify(inverse = true) {
|
||||
userTokensResponseStore.get(any())
|
||||
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val scanResponse = MockScanResponseFactory.create(
|
||||
cardConfig = GenericCardConfig(2),
|
||||
derivedKeys = emptyMap(),
|
||||
)
|
||||
|
||||
val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import com.tangem.data.account.converter.AccountListConverter
|
|||
import com.tangem.data.account.converter.createGetWalletAccountsResponse
|
||||
import com.tangem.data.account.store.AccountsResponseStore
|
||||
import com.tangem.data.account.store.AccountsResponseStoreFactory
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package com.tangem.data.account.store
|
|||
import android.content.Context
|
||||
import com.google.common.truth.Truth
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
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
|
||||
|
|
@ -19,7 +19,7 @@ class AccountsResponseStoreFactoryTest {
|
|||
private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory(
|
||||
context = context,
|
||||
moshi = moshi,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
appScope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@AfterEach
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.account.token
|
||||
|
||||
import arrow.core.right
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.data.account.converter.createGetWalletAccountsResponse
|
||||
import com.tangem.data.account.converter.createWalletAccountDTO
|
||||
import com.tangem.data.account.store.AccountsResponseStore
|
||||
|
|
@ -18,7 +19,6 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.test.core.assertEither
|
||||
import com.tangem.test.core.assertEitherLeft
|
||||
import com.tangem.test.core.assertEitherRight
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -43,7 +43,7 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
accountTokenMigrationStore = accountTokenMigrationStore,
|
||||
userTokensSaver = userTokensSaver,
|
||||
eTagsStore = eTagsStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
coroutineScope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId("011")
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
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.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Factory for creating list of [CryptoCurrency] for selected card
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface CardCryptoCurrencyFactory {
|
||||
|
||||
/**
|
||||
* Universal method for creating list of [CryptoCurrency] in [network] for any card
|
||||
*
|
||||
* @param userWalletId user wallet id that determines type of card
|
||||
* @param network network
|
||||
*/
|
||||
@Throws
|
||||
suspend fun create(userWalletId: UserWalletId, network: Network): List<CryptoCurrency> {
|
||||
return create(userWalletId = userWalletId, networks = setOf(network))[network].orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal method for creating list of [CryptoCurrency] in [networks] for any card
|
||||
*
|
||||
* @param userWalletId user wallet id that determines type of card
|
||||
* @param networks networks
|
||||
*/
|
||||
@Throws
|
||||
suspend fun create(userWalletId: UserWalletId, networks: Set<Network>): Map<Network, List<CryptoCurrency>>
|
||||
|
||||
/**
|
||||
* Universal method for creating list of [CryptoCurrency] in [network] for any card
|
||||
*
|
||||
* @param userWalletId user wallet id that determines type of card
|
||||
* @param network network
|
||||
*/
|
||||
@Throws
|
||||
suspend fun createByRawId(userWalletId: UserWalletId, network: Network.RawID): List<CryptoCurrency>
|
||||
|
||||
/**
|
||||
* Create currencies for multi currency card
|
||||
*
|
||||
* @param userWallet user wallet
|
||||
* @param networks networks
|
||||
*/
|
||||
@Throws
|
||||
suspend fun createCurrenciesForMultiCurrencyCard(
|
||||
userWallet: UserWallet,
|
||||
networks: Set<Network>,
|
||||
): Map<Network, List<CryptoCurrency>>
|
||||
|
||||
/**
|
||||
* Create default coins for multi currency card
|
||||
*
|
||||
* @param userWallet user wallet
|
||||
*/
|
||||
fun createDefaultCoinsForMultiCurrencyWallet(userWallet: UserWallet): List<CryptoCurrency.Coin>
|
||||
|
||||
/**
|
||||
* Create primary currency for single currency card
|
||||
*
|
||||
* @param userWallet user wallet
|
||||
*/
|
||||
@Throws
|
||||
fun createPrimaryCurrencyForSingleCurrencyCard(userWallet: UserWallet.Cold): CryptoCurrency
|
||||
|
||||
/**
|
||||
* Create currencies for single currency card with token (like, NODL)
|
||||
*
|
||||
* @param userWallet user wallet
|
||||
*/
|
||||
@Throws
|
||||
fun createCurrenciesForSingleCurrencyCardWithToken(userWallet: UserWallet.Cold): List<CryptoCurrency>
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
|
||||
import com.tangem.data.common.wallet.WalletServerBinder
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.isNetworkError
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.getSyncOrNull
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
@ -23,31 +21,11 @@ import timber.log.Timber
|
|||
class UserTokensSaver(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val addressesEnricher: UserTokensResponseAddressesEnricher,
|
||||
private val walletServerBinder: WalletServerBinder,
|
||||
private val pushTokensRetryerPool: RetryerPool,
|
||||
) {
|
||||
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
|
||||
|
||||
suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) {
|
||||
withContext(dispatchers.default) {
|
||||
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = true)
|
||||
|
||||
store(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
|
||||
push(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
|
||||
withContext(dispatchers.default) {
|
||||
val updatedResponse = response
|
||||
.applyCompatibility()
|
||||
.enrichIf(userWalletId = userWalletId, condition = useEnricher)
|
||||
|
||||
userTokensResponseStore.store(userWalletId = userWalletId, response = updatedResponse)
|
||||
}
|
||||
|
||||
suspend fun push(
|
||||
userWalletId: UserWalletId,
|
||||
|
|
@ -111,10 +89,6 @@ class UserTokensSaver(
|
|||
)
|
||||
}
|
||||
|
||||
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
|
||||
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
|
||||
}
|
||||
|
||||
private suspend fun UserTokensResponse.enrichIf(
|
||||
userWalletId: UserWalletId,
|
||||
condition: Boolean,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.tangem.data.common.account.WalletAccountsFetcher
|
|||
import com.tangem.data.common.cache.etag.DefaultETagsStore
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.data.common.currency.*
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.quote.DefaultQuotesFetcher
|
||||
import com.tangem.data.common.quote.QuotesFetcher
|
||||
import com.tangem.data.common.wallet.DefaultWalletServerBinder
|
||||
|
|
@ -12,8 +13,8 @@ import com.tangem.data.common.wallet.WalletServerBinder
|
|||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||
|
|
@ -23,8 +24,6 @@ import dagger.Module
|
|||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -67,19 +66,18 @@ internal object DataCommonModule {
|
|||
fun provideUserTokensSaver(
|
||||
tangemTechApi: TangemTechApi,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
addressesEnricher: UserTokensResponseAddressesEnricher,
|
||||
walletServerBinder: WalletServerBinder,
|
||||
appScope: AppCoroutineScope,
|
||||
): UserTokensSaver {
|
||||
return UserTokensSaver(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
dispatchers = dispatchers,
|
||||
addressesEnricher = addressesEnricher,
|
||||
pushTokensRetryerPool = RetryerPool(
|
||||
coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default),
|
||||
coroutineScope = appScope,
|
||||
),
|
||||
walletServerBinder = walletServerBinder,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,23 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
|||
*/
|
||||
class UserTokensBackwardCompatibility {
|
||||
|
||||
fun applyCompatibilityAndGetUpdated(userTokensResponse: UserTokensResponse): UserTokensResponse {
|
||||
return userTokensResponse.copy(
|
||||
tokens = userTokensResponse.tokens.map { token ->
|
||||
val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId]
|
||||
if (oldSavedId != null && token.id == oldSavedId) {
|
||||
Blockchain.fromNetworkId(token.networkId)?.let { blockchain ->
|
||||
token.copy(
|
||||
id = blockchain.toCoinId(),
|
||||
)
|
||||
} ?: token
|
||||
} else {
|
||||
token
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun applyCompatibilityAndGetUpdated(tokens: List<UserTokensResponse.Token>): List<UserTokensResponse.Token> {
|
||||
return tokens.map { token ->
|
||||
val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import com.tangem.datasource.api.common.response.ApiResponseError
|
|||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.WalletType
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
|
@ -23,14 +22,12 @@ class UserTokensSaverTest {
|
|||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
|
||||
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true)
|
||||
private val enricher: UserTokensResponseAddressesEnricher = mockk()
|
||||
private val walletServerBinder: WalletServerBinder = mockk()
|
||||
|
||||
private val userTokensSaver: UserTokensSaver = UserTokensSaver(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
addressesEnricher = enricher,
|
||||
walletServerBinder = walletServerBinder,
|
||||
|
|
@ -42,46 +39,11 @@ class UserTokensSaverTest {
|
|||
clearMocks(
|
||||
tangemTechApi,
|
||||
userWalletsListRepository,
|
||||
userTokensResponseStore,
|
||||
enricher,
|
||||
walletServerBinder,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet id and response WHEN store THEN should store enriched response`() = runTest {
|
||||
// GIVEN
|
||||
val userWalletId = UserWalletId("1234567890abcdef")
|
||||
val response = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val enrichedResponse = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
coEvery { enricher(userWalletId, response) } returns enrichedResponse
|
||||
|
||||
// WHEN
|
||||
userTokensSaver.store(userWalletId, response)
|
||||
|
||||
// THEN
|
||||
coVerifyOrder {
|
||||
enricher(userWalletId, response)
|
||||
userTokensResponseStore.store(userWalletId, enrichedResponse)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.saveTokens(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet id and response WHEN push AND api call fails THEN should log error and call onFailSend`() =
|
||||
runTest {
|
||||
|
|
@ -133,49 +95,4 @@ class UserTokensSaverTest {
|
|||
|
||||
assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest {
|
||||
// GIVEN
|
||||
val userWalletId = UserWalletId("1234567890abcdef")
|
||||
val userWallet = mockk<UserWallet.Cold> {
|
||||
every { this@mockk.walletId } returns userWalletId
|
||||
every { this@mockk.name } returns "Wallet"
|
||||
}
|
||||
|
||||
val response = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
walletName = userWallet.name,
|
||||
walletType = WalletType.COLD,
|
||||
)
|
||||
val enrichedResponse = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
walletName = userWallet.name,
|
||||
walletType = WalletType.COLD,
|
||||
)
|
||||
|
||||
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
coEvery { enricher(userWalletId, response) } returns enrichedResponse
|
||||
coEvery {
|
||||
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
|
||||
} returns ApiResponse.Success(Unit)
|
||||
|
||||
// WHEN
|
||||
userTokensSaver.storeAndPush(userWalletId, response)
|
||||
|
||||
// THEN
|
||||
coVerifyOrder {
|
||||
enricher(userWalletId, response)
|
||||
userWalletsListRepository.userWallets
|
||||
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,7 +151,7 @@ internal class DefaultQuotesFetcherTest {
|
|||
quotes = setOf(
|
||||
QuoteMetadata(
|
||||
cryptoCurrencyId = "ethereum",
|
||||
timestamp = DateTime.now().millis - 10_000,
|
||||
timestamp = DateTime.now().millis - 20_000,
|
||||
value = cachedQuote,
|
||||
),
|
||||
),
|
||||
|
|
@ -194,7 +194,7 @@ internal class DefaultQuotesFetcherTest {
|
|||
quotes = setOf(
|
||||
QuoteMetadata(
|
||||
cryptoCurrencyId = "ethereum",
|
||||
timestamp = DateTime.now().millis - 10_000,
|
||||
timestamp = DateTime.now().millis - 20_000,
|
||||
value = cachedQuote,
|
||||
),
|
||||
QuoteMetadata(
|
||||
|
|
@ -385,7 +385,7 @@ internal class DefaultQuotesFetcherTest {
|
|||
quotes = setOf(
|
||||
QuoteMetadata(
|
||||
cryptoCurrencyId = "solana",
|
||||
timestamp = DateTime.now().millis - 10_000,
|
||||
timestamp = DateTime.now().millis - 20_000,
|
||||
value = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.data.feedback
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
|
||||
|
||||
|
|
@ -8,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
|
|||
) : FeedbackFeatureToggles {
|
||||
|
||||
override val isUsedeskEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(name = "USEDESK_ENABLED")
|
||||
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED)
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ import com.tangem.data.common.network.NetworkFactory
|
|||
import com.tangem.data.managetokens.utils.TokenAddressesConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.card.common.extensions.canHandleBlockchain
|
||||
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
|
||||
import com.tangem.domain.card.common.extensions.supportedBlockchains
|
||||
|
|
@ -27,11 +26,9 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultCustomTokensRepository(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val networkFactory: NetworkFactory,
|
||||
|
|
@ -64,27 +61,6 @@ internal class DefaultCustomTokensRepository(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun isCurrencyNotAdded(
|
||||
userWalletId: UserWalletId,
|
||||
networkId: Network.ID,
|
||||
derivationPath: Network.DerivationPath,
|
||||
contractAddress: String?,
|
||||
): Boolean {
|
||||
return withContext(dispatchers.io) {
|
||||
val storedCurrencies = userTokensResponseStore.getSyncOrNull(userWalletId)
|
||||
|
||||
requireNotNull(storedCurrencies) {
|
||||
"User tokens not found for user wallet [$userWalletId] while checking if currency is not added"
|
||||
}
|
||||
|
||||
storedCurrencies.tokens.none { token ->
|
||||
networkId.toBlockchain().toNetworkId() == token.networkId &&
|
||||
derivationPath.value == token.derivationPath &&
|
||||
contractAddress.equals(token.contractAddress, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findToken(
|
||||
userWalletId: UserWalletId,
|
||||
contractAddress: String,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import com.tangem.datasource.api.tangemTech.models.CoinsResponse
|
|||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.orDefault
|
||||
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
|
||||
import com.tangem.domain.card.common.extensions.*
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
|
|
@ -28,7 +27,6 @@ import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
|
|||
import com.tangem.domain.managetokens.repository.ManageTokensRepository
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.DerivationIndex
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
|
|
@ -44,7 +42,6 @@ internal class DefaultManageTokensRepository(
|
|||
private val tangemTechApi: TangemTechApi,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val testnetTokensStorage: TestnetTokensStorage,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
|
|
@ -280,52 +277,6 @@ internal class DefaultManageTokensRepository(
|
|||
}
|
||||
// endregion
|
||||
|
||||
override suspend fun hasLinkedTokens(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
tempAddedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
|
||||
tempRemovedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
|
||||
): Boolean {
|
||||
val addedTokens = tempAddedTokens.mapToResponseTokens()
|
||||
val removedTokens = tempRemovedTokens.mapToResponseTokens()
|
||||
|
||||
val storedTokens = requireNotNull(
|
||||
value = getSavedUserTokensResponseSync(userWalletId),
|
||||
lazyMessage = { "Unable to find tokens response for user wallet with provided ID: $userWalletId" },
|
||||
)
|
||||
val newTokensList = storedTokens.tokens + addedTokens - removedTokens.toSet()
|
||||
|
||||
return newTokensList.any { token ->
|
||||
token.contractAddress != null &&
|
||||
token.networkId == network.backendId &&
|
||||
token.derivationPath == network.derivationPath.value
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<ManagedCryptoCurrency.Token, Set<Network>>.mapToResponseTokens(): List<UserTokensResponse.Token> {
|
||||
return flatMap { (token, networks) ->
|
||||
token.availableNetworks
|
||||
.filter { sourceNetwork -> networks.contains(sourceNetwork.network) }
|
||||
.map { sourceNetwork ->
|
||||
val networkId = sourceNetwork.network.toBlockchain().toNetworkId()
|
||||
|
||||
UserTokensResponse.Token(
|
||||
id = token.id.value,
|
||||
networkId = networkId,
|
||||
derivationPath = sourceNetwork.network.derivationPath.value,
|
||||
name = token.name,
|
||||
symbol = token.symbol,
|
||||
decimals = sourceNetwork.decimals,
|
||||
contractAddress = (sourceNetwork as? SourceNetwork.Default)?.contractAddress,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? {
|
||||
return userTokensResponseStore.getSyncOrNull(userWalletId = key)
|
||||
}
|
||||
|
||||
override suspend fun checkCurrencyUnsupportedState(
|
||||
userWalletId: UserWalletId,
|
||||
sourceNetwork: SourceNetwork,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.tangem.data.managetokens.DefaultManageTokensRepository
|
|||
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.managetokens.repository.CustomTokensRepository
|
||||
import com.tangem.domain.managetokens.repository.ManageTokensRepository
|
||||
|
|
@ -29,7 +28,6 @@ internal object ManageTokensDataModule {
|
|||
tangemTechApi: TangemTechApi,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
testnetTokensStorage: TestnetTokensStorage,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
|
|
@ -40,7 +38,6 @@ internal object ManageTokensDataModule {
|
|||
tangemTechApi = tangemTechApi,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
manageTokensUpdateFetcher = manageTokensUpdateFetcher,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
testnetTokensStorage = testnetTokensStorage,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
networkFactory = networkFactory,
|
||||
|
|
@ -54,7 +51,6 @@ internal object ManageTokensDataModule {
|
|||
fun provideCustomTokensRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
networkFactory: NetworkFactory,
|
||||
|
|
@ -62,7 +58,6 @@ internal object ManageTokensDataModule {
|
|||
return DefaultCustomTokensRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
dispatchers = dispatchers,
|
||||
networkFactory = networkFactory,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
|||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.networks.repository.DefaultNetworksRepository
|
||||
import com.tangem.data.networks.store.DefaultNetworksStatusesStore
|
||||
import com.tangem.data.networks.store.NetworksStatusesStore
|
||||
|
|
@ -15,6 +15,7 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM
|
|||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.datasource.utils.setTypes
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.networks.repository.NetworksRepository
|
||||
import com.tangem.domain.networks.utils.NetworksCleaner
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
|
|
@ -24,8 +25,6 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -37,7 +36,7 @@ internal object NetworkDataModule {
|
|||
fun provideNetworksStatusesStore(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
appScope: AppCoroutineScope,
|
||||
): NetworksStatusesStore {
|
||||
return DefaultNetworksStatusesStore(
|
||||
context = context,
|
||||
|
|
@ -49,9 +48,9 @@ internal object NetworkDataModule {
|
|||
defaultValue = emptyMap(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "networks_statuses_2") },
|
||||
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
|
||||
scope = appScope,
|
||||
),
|
||||
dispatchers = dispatchers,
|
||||
scope = appScope,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package com.tangem.data.networks.multi
|
|||
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
|
||||
import com.tangem.data.networks.store.NetworksStatusesStore
|
||||
import com.tangem.data.networks.store.setSourceAsCache
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package com.tangem.data.networks.repository
|
||||
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.networks.store.NetworksStatusesStore
|
||||
import com.tangem.data.networks.store.storeStatus
|
||||
import com.tangem.data.networks.utils.NetworkStatusFactory
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import com.tangem.data.networks.converters.SimpleNetworkStatusConverter
|
|||
import com.tangem.data.networks.models.SimpleNetworkStatus
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.network.entity.NetworkStatusDM
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
|
@ -33,11 +33,9 @@ internal class DefaultNetworksStatusesStore(
|
|||
context: Context,
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithSimpleStatus>,
|
||||
private val persistenceDataStore: DataStore<WalletIdWithStatusDM>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val scope: AppCoroutineScope,
|
||||
) : NetworksStatusesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package com.tangem.data.networks.multi
|
|||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
|
||||
import com.tangem.data.networks.store.NetworksStatusesStore
|
||||
import com.tangem.data.networks.store.setSourceAsCache
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import com.google.common.truth.Truth
|
|||
import com.tangem.blockchainsdk.models.UpdateWalletManagerResult
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.common.test.domain.walletmanager.MockUpdateWalletManagerResultFactory
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.networks.store.NetworksStatusesStore
|
||||
import com.tangem.data.networks.store.storeStatus
|
||||
import com.tangem.data.networks.utils.NetworkStatusFactory
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.networks.store
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.data.networks.toSimple
|
|||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -27,7 +27,7 @@ internal class GetTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.networks.store
|
|||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.data.networks.models.SimpleNetworkStatus
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.data.networks.toSimple
|
|||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
|
@ -33,7 +33,7 @@ internal class InitializationTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
|
||||
|
|
@ -48,7 +48,7 @@ internal class InitializationTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<SimpleNetworkStatus>>())
|
||||
|
|
@ -71,7 +71,7 @@ internal class InitializationTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
val expectedStatus = status.toSimple().copy(value = status.value.copySealed(source = StatusSource.CACHE))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.data.networks.models.SimpleNetworkStatus
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
|||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.network.NetworkStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -31,7 +31,7 @@ internal class ParameterizedStoreStatusTest(private val model: Model) {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.data.networks.models.SimpleNetworkStatus
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
|||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.network.NetworkStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -31,7 +31,7 @@ internal class ParameterizedStoreSuccessTest(private val model: Model) {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.data.networks.models.SimpleNetworkStatus
|
||||
|
|
@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple
|
|||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.network.NetworkStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -30,7 +30,7 @@ internal class ParameterizedStoreTest(private val model: Model) {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM
|
|||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.network.NetworkStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -28,7 +28,7 @@ internal class SetSourceAsCacheTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM
|
|||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.network.NetworkStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -28,7 +28,7 @@ internal class SetSourceAsOnlyCacheTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
|||
import com.tangem.datasource.local.network.entity.NetworkStatusDM
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -28,7 +28,7 @@ internal class StoreStatusTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
|
|
@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple
|
|||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -27,7 +27,7 @@ internal class StoreSuccessTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
|
|
@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple
|
|||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -27,7 +27,7 @@ internal class StoreTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.networks.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
|
|
@ -13,7 +14,6 @@ import com.tangem.domain.models.StatusSource
|
|||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.models.network.NetworkStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -31,7 +31,7 @@ internal class UpdateStatusSourceTest {
|
|||
context = mockk(),
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import com.tangem.domain.card.common.extensions.canHandleToken
|
|||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.getSyncStrict
|
||||
import com.tangem.domain.common.wallets.loadAndGet
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.onramp.model.HotCryptoCurrency
|
||||
|
|
@ -30,10 +31,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
|||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.runCatching
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.plus
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
|
@ -59,9 +59,10 @@ internal class DefaultHotCryptoRepository(
|
|||
private val walletAccountsFetcher: WalletAccountsFetcher,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
appScope: AppCoroutineScope,
|
||||
) : HotCryptoRepository {
|
||||
|
||||
private val coroutineScope = CoroutineScope(dispatchers.main + SupervisorJob())
|
||||
private val coroutineScope = appScope + dispatchers.main
|
||||
|
||||
private val hotCryptoJobHolder = JobHolder()
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import com.tangem.datasource.local.onramp.sepa.OnrampCurrentCountryByIPStore
|
|||
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.onramp.repositories.*
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -109,6 +110,7 @@ internal object OnrampDataModule {
|
|||
dispatchers: CoroutineDispatcherProvider,
|
||||
analyticsEventHandler: AnalyticsEventHandler,
|
||||
walletAccountsFetcher: WalletAccountsFetcher,
|
||||
appScope: AppCoroutineScope,
|
||||
): HotCryptoRepository {
|
||||
return DefaultHotCryptoRepository(
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
|
|
@ -119,6 +121,7 @@ internal object OnrampDataModule {
|
|||
dispatchers = dispatchers,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
walletAccountsFetcher = walletAccountsFetcher,
|
||||
appScope = appScope,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.tangem.data.qrscanning.di
|
||||
|
||||
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import dagger.Module
|
||||
|
|
@ -15,6 +18,15 @@ internal object QrScanningDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
|
||||
return DefaultQrScanningEventsRepository()
|
||||
val blockchainDataProvider = QrContentClassifierParser.DefaultBlockchainDataProvider()
|
||||
return DefaultQrScanningEventsRepository(
|
||||
qrContentClassifierParser = QrContentClassifierParser(
|
||||
blockchainDataProvider = blockchainDataProvider,
|
||||
paymentUriParsers = setOf(
|
||||
Eip681PaymentUriParser(blockchainDataProvider),
|
||||
Bip321PaymentUriParser(blockchainDataProvider),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
|
||||
internal class Bip321PaymentUriParser(
|
||||
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
|
||||
) : PaymentUriParser {
|
||||
|
||||
override fun parse(
|
||||
qrCode: String,
|
||||
coins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): PaymentUriParser.ParseResult {
|
||||
val schemeAndRest = extractSchemeAndRest(qrCode, coins)
|
||||
?: return PaymentUriParser.ParseResult.NotRecognized
|
||||
val (matchingCoins, withoutScheme) = schemeAndRest
|
||||
|
||||
val parsed = QrSentUriParser().parse(withoutScheme)
|
||||
?: return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||
)
|
||||
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
|
||||
if (matchingCurrencies.isEmpty()) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.UnsupportedNetwork(
|
||||
raw = qrCode,
|
||||
blockchain = matchingCoins.firstOrNull()?.network?.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return PaymentUriParser.ParseResult.Success(
|
||||
ClassifiedQrContent.PaymentUri(
|
||||
address = parsed.address,
|
||||
amount = parsed.amount,
|
||||
memo = parsed.memo,
|
||||
matchingCurrencies = matchingCurrencies,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractSchemeAndRest(
|
||||
qrCode: String,
|
||||
coins: List<CryptoCurrency.Coin>,
|
||||
): Pair<List<CryptoCurrency.Coin>, String>? {
|
||||
for (coin in coins) {
|
||||
val schemes = blockchainDataProvider.getShareSchemes(coin.network)
|
||||
for (scheme in schemes) {
|
||||
if (qrCode.startsWith(scheme, ignoreCase = true)) {
|
||||
val withoutScheme = qrCode.removeRange(0, scheme.length)
|
||||
val allMatchingCoins = coins.filter { c ->
|
||||
blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) }
|
||||
}
|
||||
return allMatchingCoins to withoutScheme
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import java.math.BigDecimal
|
||||
import java.math.MathContext
|
||||
|
||||
internal class Eip681PaymentUriParser(
|
||||
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
|
||||
) : PaymentUriParser {
|
||||
|
||||
override fun parse(
|
||||
qrCode: String,
|
||||
coins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): PaymentUriParser.ParseResult {
|
||||
if (!qrCode.startsWith(SCHEME)) {
|
||||
return PaymentUriParser.ParseResult.NotRecognized
|
||||
}
|
||||
|
||||
val withoutScheme = qrCode.removePrefix(SCHEME)
|
||||
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
|
||||
|
||||
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
|
||||
if (matchingCoins.isEmpty()) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.UnsupportedNetwork(
|
||||
raw = qrCode,
|
||||
blockchain = parsed.chainId?.let { blockchainDataProvider.getBlockchainNameByChainId(it) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val result = if (parsed.functionName == FUNCTION_TRANSFER) {
|
||||
if (PARAM_ADDRESS !in parsed.params) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||
)
|
||||
}
|
||||
resolveErc20Transfer(parsed, matchingCoins, allCurrencies)
|
||||
} else {
|
||||
resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
|
||||
}
|
||||
return if (result != null) {
|
||||
PaymentUriParser.ParseResult.Success(result)
|
||||
} else {
|
||||
PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.UnsupportedNetwork(
|
||||
raw = qrCode,
|
||||
blockchain = matchingCoins.firstOrNull()?.network?.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveNativeTransfer(
|
||||
parsed: Eip681Result,
|
||||
matchingCoins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): ClassifiedQrContent.PaymentUri? {
|
||||
val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull()
|
||||
|
||||
if (matchingCoins.isEmpty()) return null
|
||||
|
||||
val decimals = matchingCoins.first().decimals
|
||||
val amount = valueWei?.fromSmallestUnit(decimals)
|
||||
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
// If value is specified, this is a native coin transfer — return only coins
|
||||
// If no value, it's just an address with scheme — return all currencies on the network
|
||||
val matchingCurrencies = if (valueWei != null) {
|
||||
matchingCoins
|
||||
} else {
|
||||
allCurrencies.filter { it.network.id in matchingNetworkIds }
|
||||
}
|
||||
|
||||
return ClassifiedQrContent.PaymentUri(
|
||||
address = parsed.targetAddress,
|
||||
amount = amount,
|
||||
memo = null,
|
||||
matchingCurrencies = matchingCurrencies,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveErc20Transfer(
|
||||
parsed: Eip681Result,
|
||||
matchingCoins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): ClassifiedQrContent.PaymentUri? {
|
||||
val recipient = parsed.params[PARAM_ADDRESS] ?: return null
|
||||
val contractAddress = parsed.targetAddress
|
||||
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
|
||||
val matchingTokens = allCurrencies.filterIsInstance<CryptoCurrency.Token>()
|
||||
.filter { token ->
|
||||
token.network.id in matchingNetworkIds &&
|
||||
token.contractAddress.equals(contractAddress, ignoreCase = true)
|
||||
}
|
||||
|
||||
if (matchingTokens.isEmpty()) return null
|
||||
|
||||
val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull()
|
||||
val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals)
|
||||
|
||||
return ClassifiedQrContent.PaymentUri(
|
||||
address = recipient,
|
||||
amount = amount,
|
||||
memo = null,
|
||||
matchingCurrencies = matchingTokens,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findMatchingCoins(chainId: Long?, coins: List<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> {
|
||||
if (chainId == null) {
|
||||
return coins.filter { coin ->
|
||||
blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) }
|
||||
}
|
||||
}
|
||||
return coins.filter { coin ->
|
||||
blockchainDataProvider.getChainId(coin.network) == chainId
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEip681(withoutScheme: String): Eip681Result? {
|
||||
val match = URI_REGEX.matchEntire(withoutScheme) ?: return null
|
||||
|
||||
val targetAddress = match.groupValues[GROUP_ADDRESS].ifBlank { return null }
|
||||
val pathChainId = match.groupValues[GROUP_CHAIN_ID].toLongOrNull()
|
||||
val functionName = match.groupValues[GROUP_FUNCTION].ifBlank { null }
|
||||
val queryString = match.groupValues[GROUP_QUERY]
|
||||
|
||||
val params = parseQueryParams(queryString)
|
||||
val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull()
|
||||
|
||||
return Eip681Result(
|
||||
targetAddress = targetAddress,
|
||||
chainId = chainId,
|
||||
functionName = functionName,
|
||||
params = params,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseQueryParams(query: String): Map<String, String> {
|
||||
if (query.isBlank()) return emptyMap()
|
||||
return query.split('&').mapNotNull { param ->
|
||||
val parts = param.split('=', limit = 2)
|
||||
if (parts.size == 2) parts[0] to parts[1] else null
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun BigDecimal.fromSmallestUnit(decimals: Int): BigDecimal {
|
||||
if (decimals == 0) return this
|
||||
return this.divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL128)
|
||||
}
|
||||
|
||||
private data class Eip681Result(
|
||||
val targetAddress: String,
|
||||
val chainId: Long?,
|
||||
val functionName: String?,
|
||||
val params: Map<String, String>,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
// ethereum:<address>[@<chainId>][/<function>][?<params>]
|
||||
val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""")
|
||||
const val SCHEME = "ethereum:"
|
||||
const val FUNCTION_TRANSFER = "transfer"
|
||||
const val PARAM_VALUE = "value"
|
||||
const val PARAM_ADDRESS = "address"
|
||||
const val PARAM_UINT256 = "uint256"
|
||||
const val PARAM_CHAIN_ID = "chainId"
|
||||
const val GROUP_ADDRESS = 1
|
||||
const val GROUP_CHAIN_ID = 2
|
||||
const val GROUP_FUNCTION = 3
|
||||
const val GROUP_QUERY = 4
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
|
||||
internal interface PaymentUriParser {
|
||||
|
||||
fun parse(qrCode: String, coins: List<CryptoCurrency.Coin>, allCurrencies: List<CryptoCurrency>): ParseResult
|
||||
|
||||
sealed class ParseResult {
|
||||
/** URI format not recognized by this parser. */
|
||||
data object NotRecognized : ParseResult()
|
||||
|
||||
/** URI format recognized but resulted in an error. */
|
||||
data class RecognizedError(val error: ClassifiedQrContent.Error) : ParseResult()
|
||||
|
||||
/** Successfully parsed with matching currencies. */
|
||||
data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal class QrContentClassifierParser(
|
||||
private val blockchainDataProvider: BlockchainDataProvider,
|
||||
private val paymentUriParsers: Set<PaymentUriParser>,
|
||||
) {
|
||||
|
||||
fun parse(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
|
||||
if (qrCode.startsWith(WC_PREFIX)) {
|
||||
return ClassifiedQrContent.WalletConnect(qrCode)
|
||||
}
|
||||
|
||||
if (isDAppWcUrl(qrCode)) {
|
||||
return ClassifiedQrContent.WalletConnect(qrCode)
|
||||
}
|
||||
|
||||
val coins = userCurrencies.filterIsInstance<CryptoCurrency.Coin>()
|
||||
val uniqueCoins = coins.distinctBy { it.network.id }
|
||||
|
||||
when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) {
|
||||
is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content
|
||||
is PaymentUriParser.ParseResult.RecognizedError -> return paymentUriResult.error
|
||||
is PaymentUriParser.ParseResult.NotRecognized -> Unit
|
||||
}
|
||||
|
||||
val matchingCoins = uniqueCoins
|
||||
.filter { coin -> blockchainDataProvider.validateAddress(coin.network, qrCode) }
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
|
||||
if (matchingNetworkIds.isNotEmpty()) {
|
||||
val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds }
|
||||
|
||||
return ClassifiedQrContent.PlainAddress(
|
||||
address = qrCode,
|
||||
matchingCurrencies = matchingCurrencies,
|
||||
)
|
||||
}
|
||||
|
||||
val supportedBlockchain = blockchainDataProvider.findSupportedBlockchainName(qrCode)
|
||||
if (supportedBlockchain != null) {
|
||||
return ClassifiedQrContent.Error.UnsupportedNetwork(
|
||||
raw = qrCode,
|
||||
blockchain = supportedBlockchain,
|
||||
)
|
||||
}
|
||||
|
||||
return ClassifiedQrContent.Error.Unrecognized(qrCode)
|
||||
}
|
||||
|
||||
private fun tryParsePaymentUri(
|
||||
qrCode: String,
|
||||
coins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): PaymentUriParser.ParseResult {
|
||||
return paymentUriParsers.firstNotNullOfOrNull { parser ->
|
||||
parser.parse(qrCode, coins, allCurrencies).takeUnless { it is PaymentUriParser.ParseResult.NotRecognized }
|
||||
} ?: PaymentUriParser.ParseResult.NotRecognized
|
||||
}
|
||||
|
||||
private fun isDAppWcUrl(qrCode: String): Boolean {
|
||||
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
|
||||
|
||||
val uriParser = QrSentUriParser()
|
||||
val uriParam = uriParser.extractParameters(qrCode)[PARAM_URI] ?: return false
|
||||
val decodedUri = runCatching { URLDecoder.decode(
|
||||
uriParam,
|
||||
QrSentUriParser.CHARSET_UTF8,
|
||||
) }.getOrDefault(uriParam)
|
||||
return decodedUri.startsWith(WC_PREFIX)
|
||||
}
|
||||
|
||||
internal interface BlockchainDataProvider {
|
||||
fun getShareSchemes(network: Network): List<String>
|
||||
fun validateAddress(network: Network, address: String): Boolean
|
||||
fun getChainId(network: Network): Long?
|
||||
fun findSupportedBlockchainName(address: String): String?
|
||||
fun getBlockchainNameByChainId(chainId: Long): String?
|
||||
}
|
||||
|
||||
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
|
||||
override fun getShareSchemes(network: Network): List<String> {
|
||||
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
override fun validateAddress(network: Network, address: String): Boolean {
|
||||
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
override fun getChainId(network: Network): Long? {
|
||||
return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull()
|
||||
}
|
||||
|
||||
override fun findSupportedBlockchainName(address: String): String? {
|
||||
return Blockchain.entries
|
||||
.filter { !it.isTestnet() }
|
||||
.firstOrNull { blockchain ->
|
||||
runCatching { blockchain.validateAddress(address) }.getOrDefault(false)
|
||||
}?.fullName
|
||||
}
|
||||
|
||||
override fun getBlockchainNameByChainId(chainId: Long): String? {
|
||||
return Blockchain.entries
|
||||
.filter { !it.isTestnet() }
|
||||
.firstOrNull { blockchain ->
|
||||
runCatching { blockchain.getChainId()?.toLong() == chainId }.getOrDefault(false)
|
||||
}?.fullName
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_PREFIX = "http://"
|
||||
const val HTTPS_PREFIX = "https://"
|
||||
const val PARAM_URI = "uri"
|
||||
const val WC_PREFIX = "wc:"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal class QrSentUriParser {
|
||||
|
||||
data class Result(
|
||||
val address: String,
|
||||
val amount: BigDecimal?,
|
||||
val memo: String?,
|
||||
val params: Map<String, String>,
|
||||
)
|
||||
|
||||
fun parse(withoutScheme: String): Result? {
|
||||
val address = withoutScheme.takeWhile {
|
||||
it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER
|
||||
}
|
||||
if (address.isBlank()) return null
|
||||
|
||||
val params = extractParameters(withoutScheme)
|
||||
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
|
||||
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
|
||||
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
|
||||
}
|
||||
|
||||
return Result(
|
||||
address = address,
|
||||
amount = amount,
|
||||
memo = memo,
|
||||
params = params,
|
||||
)
|
||||
}
|
||||
|
||||
fun extractParameters(from: String): Map<String, String> {
|
||||
val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "")
|
||||
if (paramsBlock.isBlank()) return emptyMap()
|
||||
|
||||
return paramsBlock.split(PARAMS_DELIMITER)
|
||||
.mapNotNull { param ->
|
||||
val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2)
|
||||
if (parts.size == 2) parts[0].lowercase() to parts[1] else null
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHAIN_DELIMITER = '@'
|
||||
const val FUNCTION_DELIMITER = '/'
|
||||
const val PARAM_DELIMITER = '?'
|
||||
const val PARAMS_DELIMITER = '&'
|
||||
const val PARAM_VALUE_DELIMITER = '='
|
||||
const val PARAM_AMOUNT = "amount"
|
||||
const val PARAM_MEMO = "memo"
|
||||
const val PARAM_MESSAGE = "message"
|
||||
const val PARAM_ADDRESS = "address"
|
||||
const val PARAM_VALUE = "value"
|
||||
const val PARAM_UINT256 = "uint256"
|
||||
const val CHARSET_UTF8 = "UTF-8"
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@ package com.tangem.data.qrscanning.repository
|
|||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.data.qrscanning.parser.QrSentUriParser
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.domain.qrscanning.models.QrResult
|
||||
import com.tangem.domain.qrscanning.models.RawQrResult
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
|
|
@ -14,9 +17,11 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.yield
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
||||
internal class DefaultQrScanningEventsRepository(
|
||||
private val qrContentClassifierParser: QrContentClassifierParser,
|
||||
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
|
||||
) : QrScanningEventsRepository {
|
||||
|
||||
private data class QrScanningEvent(val qrCode: RawQrResult)
|
||||
|
||||
|
|
@ -37,120 +42,50 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
|||
|
||||
override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult {
|
||||
val withoutSchema = stripSchema(qrCode, cryptoCurrency)
|
||||
val parsed = paymentUriParser.parse(withoutSchema)
|
||||
?: return QrResult(address = withoutSchema)
|
||||
|
||||
// A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care
|
||||
// about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`.
|
||||
//
|
||||
// We're extracting the destination address by parsing the given string until we meet
|
||||
// any of the possible string delimiters (@ ? /).
|
||||
val address = withoutSchema.takeWhile { char ->
|
||||
char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER
|
||||
val result = QrResult(address = parsed.address)
|
||||
result.amount = parsed.amount
|
||||
result.memo = parsed.memo
|
||||
|
||||
// ERC-681: if 'address' parameter exists, currency must be a token,
|
||||
// and the URI address must match the token's contract address.
|
||||
parsed.params[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue ->
|
||||
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
|
||||
if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) {
|
||||
result.address = addressValue
|
||||
} else {
|
||||
return QrResult()
|
||||
}
|
||||
}
|
||||
|
||||
val result = QrResult(address = address)
|
||||
|
||||
extractParameters(withoutSchema)
|
||||
.forEach { entry ->
|
||||
when (entry.key) {
|
||||
Parameter.Amount -> {
|
||||
// According to BIP-0021, the value is specified in decimals. No conversion needed
|
||||
result.amount = entry.value.parseBigDecimalOrNull()
|
||||
}
|
||||
Parameter.Message,
|
||||
Parameter.Memo,
|
||||
-> {
|
||||
result.memo = URLDecoder.decode(entry.value, "UTF-8")
|
||||
}
|
||||
Parameter.Address -> {
|
||||
// If 'address' parameter is exists, then currency must be TOKEN.
|
||||
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
|
||||
|
||||
// Overrides destination address for token transfers (ERC-681)
|
||||
// `address` parameter is used only if the contract address, encoded in the QR,
|
||||
// matches the contract address of the token.
|
||||
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
|
||||
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
|
||||
result.address = entry.value
|
||||
} else {
|
||||
return QrResult()
|
||||
}
|
||||
}
|
||||
Parameter.Value,
|
||||
Parameter.Uint256,
|
||||
-> {
|
||||
// Extra convert parses scientific notation to decimal
|
||||
// This is necessary to be able comparing BigDecimal values
|
||||
result.amount = entry.value.parseBigDecimalOrNull()
|
||||
?.toPlainString()?.toBigDecimalOrNull()
|
||||
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
|
||||
}
|
||||
}
|
||||
}
|
||||
// ERC-681: value/uint256 is in the smallest unit, needs conversion
|
||||
val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE]
|
||||
?: parsed.params[QrSentUriParser.PARAM_UINT256]
|
||||
if (valueStr != null) {
|
||||
result.amount = valueStr.parseBigDecimalOrNull()
|
||||
?.toPlainString()?.toBigDecimalOrNull()
|
||||
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun classify(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
|
||||
return qrContentClassifierParser.parse(qrCode, userCurrencies)
|
||||
}
|
||||
|
||||
private fun stripSchema(raw: String, currency: CryptoCurrency): String {
|
||||
val qrSchemas = currency.network.toBlockchain().getShareScheme()
|
||||
|
||||
// The most specific (i.e. the most lengthy) prefixes always come first
|
||||
qrSchemas
|
||||
.sortedByDescending { it.length }
|
||||
.forEach { schema ->
|
||||
val stripped = raw.split(schema)
|
||||
|
||||
if (stripped.size > 1) return stripped.last()
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun extractParameters(from: String): Map<Parameter, String> {
|
||||
val parametersBlock = from.substringAfter(PARAM_DELIMITER)
|
||||
if (parametersBlock.isBlank()) return emptyMap()
|
||||
|
||||
val paramList = parametersBlock.split(PARAMS_DELIMITER)
|
||||
.mapNotNull { param ->
|
||||
val parameterWithValue = param.split(PARAM_VALUE_DELIMITER)
|
||||
if (parameterWithValue.size == 2) {
|
||||
val name = Parameter.getParam(parameterWithValue.first())
|
||||
val value = parameterWithValue.last()
|
||||
|
||||
if (name != null) {
|
||||
name to value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associate { it }
|
||||
|
||||
return paramList
|
||||
}
|
||||
|
||||
private enum class Parameter {
|
||||
Amount,
|
||||
Message,
|
||||
Memo,
|
||||
Address,
|
||||
Value,
|
||||
Uint256,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun getParam(name: String): Parameter? {
|
||||
return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// See https://eips.ethereum.org/EIPS/eip-681 for details.
|
||||
const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ]
|
||||
const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ]
|
||||
const val PARAM_DELIMITER = '?' // BIP-021, ERC-681
|
||||
const val PARAMS_DELIMITER = '&'
|
||||
const val PARAM_VALUE_DELIMITER = '='
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
package com.tangem.data.qrscanning
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class Bip321PaymentUriParserTest {
|
||||
|
||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||
every { getShareSchemes(any()) } returns emptyList()
|
||||
}
|
||||
private val parser = Bip321PaymentUriParser(blockchainDataProvider)
|
||||
|
||||
// region Basic parsing
|
||||
|
||||
@Test
|
||||
fun `bitcoin URI with address and amount`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
assertThat(result.amount!!.compareTo(BigDecimal("0.5"))).isEqualTo(0)
|
||||
assertThat(result.memo).isNull()
|
||||
assertThat(result.matchingCurrencies).containsExactly(bitcoinCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bitcoin URI with address only`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
assertThat(result.amount).isNull()
|
||||
assertThat(result.memo).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bitcoin URI with amount and message`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
assertThat(result.amount!!.compareTo(BigDecimal("1.23"))).isEqualTo(0)
|
||||
assertThat(result.memo).isEqualTo("Donation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bitcoin URI with label and message`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.memo).isEqualTo("Payment")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Scheme matching
|
||||
|
||||
@Test
|
||||
fun `litecoin URI matches litecoin coin`() {
|
||||
every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10",
|
||||
coins = listOf(litecoinCoin),
|
||||
allCurrencies = listOf(litecoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj")
|
||||
assertThat(result.amount!!.compareTo(BigDecimal("10"))).isEqualTo(0)
|
||||
assertThat(result.matchingCurrencies).containsExactly(litecoinCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no matching scheme returns NotRecognized`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "dogecoin:DAddress?amount=100",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ethereum scheme matches as Success`() {
|
||||
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient?value=1000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case insensitive scheme matching`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Includes tokens on matching network
|
||||
|
||||
@Test
|
||||
fun `includes tokens on matching network`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val btcToken = buildToken("bitcoin", "RUNE", "contractAddr")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin, btcToken),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.matchingCurrencies).containsExactly(bitcoinCoin, btcToken)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Edge cases
|
||||
|
||||
@Test
|
||||
fun `empty qr code returns NotRecognized`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain address returns NotRecognized`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bitcoin URI with memo param`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.memo).isEqualTo("TestMemo")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
|
||||
return (this as? PaymentUriParser.ParseResult.Success)?.content
|
||||
}
|
||||
|
||||
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)
|
||||
private val litecoinCoin = buildCoin("litecoin", decimals = 8)
|
||||
private val ethereumCoin = buildCoin("ethereum", decimals = 18)
|
||||
|
||||
private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin {
|
||||
return CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = rawNetworkId,
|
||||
symbol = rawNetworkId.take(3).uppercase(),
|
||||
decimals = decimals,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token {
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = symbol,
|
||||
symbol = symbol,
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = contractAddress,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNetwork(rawNetworkId: String): Network {
|
||||
return Network(
|
||||
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
||||
backendId = rawNetworkId,
|
||||
name = rawNetworkId,
|
||||
currencySymbol = rawNetworkId.take(3).uppercase(),
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = false,
|
||||
canHandleTokens = false,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.qrscanning
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -13,7 +14,8 @@ import java.math.BigDecimal
|
|||
|
||||
internal class DefaultQrScanningEventsRepositoryTest {
|
||||
|
||||
private val repository = DefaultQrScanningEventsRepository()
|
||||
private val qrContentClassifier: QrContentClassifierParser = mockk()
|
||||
private val repository = DefaultQrScanningEventsRepository(qrContentClassifier)
|
||||
|
||||
private val cryptoCurrencyId = mockk<CryptoCurrency.ID>()
|
||||
private val network = mockk<Network>()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
package com.tangem.data.qrscanning
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class Eip681PaymentUriParserTest {
|
||||
|
||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||
every { getShareSchemes(any()) } returns emptyList()
|
||||
every { getChainId(any()) } returns null
|
||||
every { getBlockchainNameByChainId(any()) } returns null
|
||||
}
|
||||
private val parser = Eip681PaymentUriParser(blockchainDataProvider)
|
||||
|
||||
// region Native transfer
|
||||
|
||||
@Test
|
||||
fun `native transfer with chain_id and value`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@1?value=1500000000000000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("0xRecipient")
|
||||
assertThat(result.amount!!.compareTo(BigDecimal("1.5"))).isEqualTo(0)
|
||||
assertThat(result.memo).isNull()
|
||||
assertThat(result.matchingCurrencies).containsExactly(ethereumCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer without value`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@1",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("0xRecipient")
|
||||
assertThat(result.amount).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer without chain_id falls back to scheme matching`() {
|
||||
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient?value=1000000000000000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("0xRecipient")
|
||||
assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer with value returns only coins`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@1?value=1000000000000000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer without value returns all currencies on matching network`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@1",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin, usdcToken)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ERC-20 transfer
|
||||
|
||||
@Test
|
||||
fun `ERC-20 transfer with contract, recipient and amount`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient&uint256=1000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("0xRecipient")
|
||||
assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0)
|
||||
assertThat(result.matchingCurrencies).containsExactly(usdcToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ERC-20 transfer without amount`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("0xRecipient")
|
||||
assertThat(result.amount).isNull()
|
||||
assertThat(result.matchingCurrencies).containsExactly(usdcToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ERC-20 transfer with unknown token returns UnsupportedNetwork error`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xUnknownContract@1/transfer?address=0xRecipient&uint256=1000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
|
||||
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
|
||||
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ERC-20 transfer without address param returns Unrecognized error`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?uint256=1000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
|
||||
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
|
||||
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
@Test
|
||||
fun `ERC-20 transfer with chainId as query param`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdtToken = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7/transfer?address=0x3D709aC89d780312677519c3AfC13f390C819531&uint256=30000000&chainId=1",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdtToken),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("0x3D709aC89d780312677519c3AfC13f390C819531")
|
||||
assertThat(result.amount!!.compareTo(BigDecimal("30"))).isEqualTo(0)
|
||||
assertThat(result.matchingCurrencies).containsExactly(usdtToken)
|
||||
}
|
||||
|
||||
// region Chain ID matching
|
||||
|
||||
@Test
|
||||
fun `chain_id mismatch returns UnsupportedNetwork error`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@137?value=1000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
|
||||
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
|
||||
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chain_id matches correct network among multiple`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
every { blockchainDataProvider.getChainId(polygonCoin.network) } returns 137L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@137?value=1000000000000000000",
|
||||
coins = listOf(ethereumCoin, polygonCoin),
|
||||
allCurrencies = listOf(ethereumCoin, polygonCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.matchingCurrencies).containsExactly(polygonCoin)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Non-ethereum schemes
|
||||
|
||||
@Test
|
||||
fun `non-ethereum scheme returns NotRecognized`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty qr code returns NotRecognized`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ethereum scheme with blank address returns NotRecognized`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:?value=1000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
|
||||
return (this as? PaymentUriParser.ParseResult.Success)?.content
|
||||
}
|
||||
|
||||
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)
|
||||
private val ethereumCoin = buildCoin("ethereum", decimals = 18)
|
||||
private val polygonCoin = buildCoin("polygon", decimals = 18)
|
||||
|
||||
private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin {
|
||||
return CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = rawNetworkId,
|
||||
symbol = rawNetworkId.take(3).uppercase(),
|
||||
decimals = decimals,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token {
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = symbol,
|
||||
symbol = symbol,
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = contractAddress,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNetwork(rawNetworkId: String): Network {
|
||||
return Network(
|
||||
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
||||
backendId = rawNetworkId,
|
||||
name = rawNetworkId,
|
||||
currencySymbol = rawNetworkId.take(3).uppercase(),
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = false,
|
||||
canHandleTokens = false,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
package com.tangem.data.qrscanning
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.qrscanning.parser.PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class QrContentClassifierTest {
|
||||
|
||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||
every { getShareSchemes(any()) } returns emptyList()
|
||||
every { validateAddress(any(), any()) } returns false
|
||||
every { getChainId(any()) } returns null
|
||||
every { findSupportedBlockchainName(any()) } returns null
|
||||
every { getBlockchainNameByChainId(any()) } returns null
|
||||
}
|
||||
private val paymentUriParser = mockk<PaymentUriParser> {
|
||||
every { parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.NotRecognized
|
||||
}
|
||||
private val classifier = QrContentClassifierParser(
|
||||
blockchainDataProvider = blockchainDataProvider,
|
||||
paymentUriParsers = setOf(paymentUriParser),
|
||||
)
|
||||
|
||||
// region WalletConnect
|
||||
|
||||
@Test
|
||||
fun `WalletConnect URI is classified correctly`() {
|
||||
val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2"
|
||||
val result = classifier.parse(uri, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
|
||||
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WalletConnect URI takes priority over address matching`() {
|
||||
val uri = "wc:something"
|
||||
every { blockchainDataProvider.validateAddress(any(), uri) } returns true
|
||||
|
||||
val result = classifier.parse(uri, listOf(bitcoinCoin, ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dApp URL with wc uri query param is classified as WalletConnect`() {
|
||||
val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123"
|
||||
|
||||
val result = classifier.parse(dAppUrl, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
|
||||
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP URL without wc uri param is not classified as WalletConnect`() {
|
||||
val url = "https://example.com/page?foo=bar"
|
||||
|
||||
val result = classifier.parse(url, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP URL with uri param not starting with wc is not WalletConnect`() {
|
||||
val url = "https://example.com/page?uri=https://other.com"
|
||||
|
||||
val result = classifier.parse(url, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region PaymentUri delegation
|
||||
|
||||
@Test
|
||||
fun `PaymentUri is returned when parser matches`() {
|
||||
val expectedUri = ClassifiedQrContent.PaymentUri(
|
||||
address = "0xRecipient",
|
||||
amount = BigDecimal("1.5"),
|
||||
memo = null,
|
||||
matchingCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri)
|
||||
|
||||
val result = classifier.parse("ethereum:0xRecipient@1?value=1500000000000000000", listOf(ethereumCoin))
|
||||
|
||||
assertThat(result).isEqualTo(expectedUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PaymentUri takes priority over plain address match`() {
|
||||
val expectedUri = ClassifiedQrContent.PaymentUri(
|
||||
address = "0xRecipient",
|
||||
amount = null,
|
||||
memo = null,
|
||||
matchingCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri)
|
||||
every { blockchainDataProvider.validateAddress(ethereumCoin.network, any()) } returns true
|
||||
|
||||
val result = classifier.parse("ethereum:0xRecipient", listOf(ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region PlainAddress
|
||||
|
||||
@Test
|
||||
fun `Plain address matches single currency`() {
|
||||
val address = "0x1234567890abcdef1234567890abcdef12345678"
|
||||
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
|
||||
|
||||
val result = classifier.parse(address, listOf(ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
|
||||
val plain = result as ClassifiedQrContent.PlainAddress
|
||||
assertThat(plain.address).isEqualTo(address)
|
||||
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Plain address matches multiple currencies`() {
|
||||
val address = "0x1234567890abcdef1234567890abcdef12345678"
|
||||
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
|
||||
every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true
|
||||
|
||||
val result = classifier.parse(address, listOf(ethereumCoin, bscCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
|
||||
val plain = result as ClassifiedQrContent.PlainAddress
|
||||
assertThat(plain.matchingCurrencies).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PlainAddress includes tokens on matching networks`() {
|
||||
val address = "0x1234567890abcdef1234567890abcdef12345678"
|
||||
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = classifier.parse(address, listOf(ethereumCoin, usdcToken))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
|
||||
val plain = result as ClassifiedQrContent.PlainAddress
|
||||
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin, usdcToken)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Unknown
|
||||
|
||||
@Test
|
||||
fun `Random string returns Unknown`() {
|
||||
val result = classifier.parse("hello world", listOf(bitcoinCoin, ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
assertThat((result as ClassifiedQrContent.Error.Unrecognized).raw).isEqualTo("hello world")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Empty string returns Unknown`() {
|
||||
val result = classifier.parse("", listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Empty currencies list returns Unknown`() {
|
||||
val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList())
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Tokens alone without Coins cannot match addresses`() {
|
||||
val token = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7")
|
||||
|
||||
val result = classifier.parse("0x1234", listOf(token))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private val bitcoinCoin = buildCoin("bitcoin")
|
||||
private val ethereumCoin = buildCoin("ethereum")
|
||||
private val bscCoin = buildCoin("bsc")
|
||||
|
||||
private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin {
|
||||
return CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = rawNetworkId,
|
||||
symbol = rawNetworkId.take(3).uppercase(),
|
||||
decimals = 8,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token {
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = symbol,
|
||||
symbol = symbol,
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = contractAddress,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNetwork(rawNetworkId: String): Network {
|
||||
return Network(
|
||||
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
||||
backendId = rawNetworkId,
|
||||
name = rawNetworkId,
|
||||
currencySymbol = rawNetworkId.take(3).uppercase(),
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = false,
|
||||
canHandleTokens = false,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -14,17 +14,15 @@ import com.tangem.datasource.di.NetworkMoshi
|
|||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -36,7 +34,7 @@ internal object QuotesDataModule {
|
|||
fun provideQuotesStoreV2(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
appScope: AppCoroutineScope,
|
||||
): QuotesStatusesStore {
|
||||
return DefaultQuotesStatusesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
|
|
@ -47,9 +45,9 @@ internal object QuotesDataModule {
|
|||
defaultValue = emptyMap(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "quotes") },
|
||||
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
|
||||
scope = appScope,
|
||||
),
|
||||
dispatchers = dispatchers,
|
||||
scope = appScope,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -65,13 +63,13 @@ internal object QuotesDataModule {
|
|||
appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
quotesStatusesStore: QuotesStatusesStore,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
coroutineScope: AppCoroutineScope,
|
||||
): MultiQuoteUpdater {
|
||||
return DefaultMultiQuoteUpdater(
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStatusesStore = quotesStatusesStore,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
dispatchers = dispatchers,
|
||||
coroutineScope = coroutineScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,16 +4,14 @@ import androidx.annotation.VisibleForTesting
|
|||
import arrow.core.left
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.core.utils.EitherFlow
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import timber.log.Timber
|
||||
|
|
@ -32,10 +30,9 @@ internal class DefaultMultiQuoteUpdater(
|
|||
private val appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val coroutineScope: AppCoroutineScope,
|
||||
) : MultiQuoteUpdater {
|
||||
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
private val updaterHolder = JobHolder()
|
||||
|
||||
override fun subscribe() {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ import androidx.datastore.core.DataStore
|
|||
import com.tangem.data.quotes.converter.QuoteStatusConverter
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
|
@ -29,11 +27,9 @@ internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
|
|||
internal class DefaultQuotesStatusesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<Set<QuoteStatus>>,
|
||||
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val scope: AppCoroutineScope,
|
||||
) : QuotesStatusesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val cachedStatuses = persistenceDataStore.data.firstOrNull()
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ package com.tangem.data.quotes.multi
|
|||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -26,7 +26,7 @@ internal class DefaultMultiQuoteStatusUpdaterTest {
|
|||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStatusesStore = quotesStore,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
coroutineScope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
|
|
@ -9,7 +10,6 @@ import com.tangem.domain.models.StatusSource
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
|
|
@ -39,7 +39,7 @@ internal class QuotesStatusesStoreExtTest {
|
|||
store = DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.quotes.store
|
|||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
|
|
@ -11,7 +12,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
|
@ -53,7 +53,7 @@ internal class QuotesStatusesStoreTest {
|
|||
store = DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ internal class QuotesStatusesStoreTest {
|
|||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
|
@ -93,7 +93,7 @@ internal class QuotesStatusesStoreTest {
|
|||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
|
@ -120,7 +120,7 @@ internal class QuotesStatusesStoreTest {
|
|||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore
|
|||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
|
||||
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
|
||||
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
|
||||
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
|
||||
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -27,12 +27,12 @@ internal object StakingBalanceSupplierModule {
|
|||
@Singleton
|
||||
fun provideStakingBalancesStore(
|
||||
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
scope: AppCoroutineScope,
|
||||
): StakeKitBalancesStore {
|
||||
return DefaultStakeKitBalancesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = dispatchers,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -40,12 +40,12 @@ internal object StakingBalanceSupplierModule {
|
|||
@Singleton
|
||||
fun provideP2PEthPoolBalancesStore(
|
||||
persistenceStore: DataStore<Map<String, Set<P2PEthPoolAccountResponse>>>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
scope: AppCoroutineScope,
|
||||
): P2PEthPoolBalancesStore {
|
||||
return DefaultP2PEthPoolBalancesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = dispatchers,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ import androidx.datastore.core.DataStore
|
|||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
|
||||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
|
@ -33,11 +31,9 @@ internal typealias WalletIdWithP2PEthPoolResponses = Map<String, Set<P2PEthPoolA
|
|||
internal class DefaultP2PEthPoolBalancesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithP2PStakingBalances>,
|
||||
private val persistenceStore: DataStore<WalletIdWithP2PEthPoolResponses>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val scope: AppCoroutineScope,
|
||||
) : P2PEthPoolBalancesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val cachedData = persistenceStore.data.firstOrNull() ?: return@launch
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ import androidx.datastore.core.DataStore
|
|||
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.token.converter.StakingBalanceConverter
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
|
@ -33,11 +31,9 @@ internal typealias WalletIdWithStakingBalances = Map<UserWalletId, Set<StakingBa
|
|||
internal class DefaultStakeKitBalancesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithStakingBalances>,
|
||||
private val persistenceStore: DataStore<WalletIdWithWrappers>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val scope: AppCoroutineScope,
|
||||
) : StakeKitBalancesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.data.staking.toggles
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.domain.staking.toggles.StakingFeatureToggles
|
||||
|
||||
|
|
@ -8,5 +9,5 @@ internal class DefaultStakingFeatureToggles(
|
|||
) : StakingFeatureToggles {
|
||||
|
||||
override val isEthStakingEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled("STAKING_ETH_ENABLED")
|
||||
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.data.staking.toDomain
|
||||
|
|
@ -8,7 +9,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
|||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ internal class StakingBalancesStoreGetMethodTest {
|
|||
private val store = DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ package com.tangem.data.staking.store
|
|||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.data.staking.toDomain
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
|
@ -30,7 +30,7 @@ internal class StakingBalancesStoreInitializationTest {
|
|||
DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
|
||||
|
|
@ -44,7 +44,7 @@ internal class StakingBalancesStoreInitializationTest {
|
|||
DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<StakingBalance>>())
|
||||
|
|
@ -66,7 +66,7 @@ internal class StakingBalancesStoreInitializationTest {
|
|||
DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
val expected = mapOf(userWalletId to setOf(wrapper.toDomain()))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.data.staking.toDomain
|
||||
|
|
@ -10,7 +11,6 @@ import com.tangem.domain.models.StatusSource
|
|||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -26,7 +26,7 @@ internal class StakingBalancesStoreUpdateMethodsTest {
|
|||
private val store = DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
|||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.domain.swap.models.*
|
||||
import com.tangem.domain.swap.models.SwapAmountType
|
||||
import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -177,12 +178,26 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
userWallet: UserWallet,
|
||||
fromCryptoCurrency: CryptoCurrency,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
fromAmount: BigDecimal,
|
||||
amount: BigDecimal,
|
||||
amountType: SwapAmountType,
|
||||
provider: ExpressProvider,
|
||||
rateType: ExpressRateType,
|
||||
): SwapQuoteModel = withContext(coroutineDispatcher.io) {
|
||||
val response = tangemExpressApi.getExchangeQuote(
|
||||
fromAmount = fromAmount.movePointRight(fromCryptoCurrency.decimals).toString(),
|
||||
fromAmount = if (amountType == SwapAmountType.From) {
|
||||
amount.movePointRight(
|
||||
fromCryptoCurrency.decimals,
|
||||
).toString()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
toAmount = if (amountType == SwapAmountType.To) {
|
||||
amount.movePointRight(
|
||||
toCryptoCurrency.decimals,
|
||||
).toString()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
fromNetwork = fromCryptoCurrency.network.backendId,
|
||||
fromContractAddress = fromCryptoCurrency.getContractAddress(),
|
||||
fromDecimals = fromCryptoCurrency.decimals,
|
||||
|
|
@ -199,10 +214,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
).getOrThrow()
|
||||
|
||||
val toTokenAmount = requireNotNull(response.toAmount.toBigDecimalOrNull()?.movePointLeft(response.toDecimals))
|
||||
val fromTokenAmount = response.fromAmount.toBigDecimalOrNull()?.movePointLeft(response.fromDecimals)
|
||||
|
||||
return@withContext SwapQuoteModel(
|
||||
provider = provider,
|
||||
toTokenAmount = toTokenAmount,
|
||||
fromTokenAmount = fromTokenAmount,
|
||||
allowanceContract = response.allowanceContract,
|
||||
)
|
||||
}
|
||||
|
|
@ -211,7 +228,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
userWallet: UserWallet,
|
||||
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
fromAmount: String,
|
||||
amount: String,
|
||||
amountType: SwapAmountType,
|
||||
toAddress: String,
|
||||
toExtraId: String?,
|
||||
expressProvider: ExpressProvider,
|
||||
|
|
@ -241,7 +259,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
toAddress = toAddress,
|
||||
fromDecimals = fromCurrency.decimals,
|
||||
toDecimals = toCryptoCurrency.decimals,
|
||||
fromAmount = fromAmount,
|
||||
fromAmount = if (amountType == SwapAmountType.From) amount else null,
|
||||
toAmount = if (amountType == SwapAmountType.To) amount else null,
|
||||
providerId = expressProvider.providerId,
|
||||
rateType = rateType.name.lowercase(),
|
||||
requestId = requestId,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ dependencies {
|
|||
// region Project - Domain
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.express)
|
||||
|
|
|
|||
|
|
@ -1,35 +1,30 @@
|
|||
package com.tangem.data.tokens
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.right
|
||||
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||
import com.tangem.datasource.api.tangemTech.models.account.flattenTokens
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.getSyncStrict
|
||||
import com.tangem.domain.core.utils.catchOn
|
||||
import com.tangem.domain.express.ExpressServiceFetcher
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher.Params
|
||||
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletAccountListFetcher.Params
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
||||
/**
|
||||
* Implementation of [MultiWalletCryptoCurrenciesFetcher] that fetches crypto currencies of all accounts
|
||||
* Implementation of [MultiWalletAccountListFetcher] that fetches the account list for a multi-currency wallet
|
||||
* by delegating to [WalletAccountsFetcher].
|
||||
*
|
||||
* @property userWalletsListRepository repository to get user wallets
|
||||
* @property walletAccountsFetcher instance of [WalletAccountsFetcher] to fetch accounts for a multi wallet
|
||||
* @property expressServiceFetcher fetcher of express service
|
||||
* @property dispatchers dispatchers
|
||||
* @property dispatchers provider for coroutine dispatchers used to run fetch operations
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class AccountListCryptoCurrenciesFetcher(
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val walletAccountsFetcher: WalletAccountsFetcher,
|
||||
private val expressServiceFetcher: ExpressServiceFetcher,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiWalletCryptoCurrenciesFetcher {
|
||||
) : MultiWalletAccountListFetcher {
|
||||
|
||||
override suspend fun invoke(params: Params): Either<Throwable, Unit> {
|
||||
return Either.catchOn(dispatchers.default) {
|
||||
|
|
@ -37,16 +32,7 @@ internal class AccountListCryptoCurrenciesFetcher(
|
|||
|
||||
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
|
||||
|
||||
val response = walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
|
||||
|
||||
expressServiceFetcher.fetch(
|
||||
userWallet = userWallet,
|
||||
assetIds = response.flattenTokens().mapTo(hashSetOf()) {
|
||||
ExpressAsset.ID(networkId = it.networkId, contractAddress = it.contractAddress)
|
||||
},
|
||||
)
|
||||
|
||||
Unit.right()
|
||||
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,7 @@ package com.tangem.data.tokens.di
|
|||
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||
import com.tangem.data.tokens.AccountListCryptoCurrenciesFetcher
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.express.ExpressServiceFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -14,20 +13,18 @@ import javax.inject.Singleton
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal class MultiWalletCryptoCurrenciesFetcherModule {
|
||||
internal class MultiWalletAccountListFetcherModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideMultiWalletCryptoCurrenciesFetcher(
|
||||
fun provideMultiWalletAccountListFetcher(
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
walletAccountsFetcher: WalletAccountsFetcher,
|
||||
expressServiceFetcher: ExpressServiceFetcher,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): MultiWalletCryptoCurrenciesFetcher {
|
||||
): MultiWalletAccountListFetcher {
|
||||
return AccountListCryptoCurrenciesFetcher(
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
walletAccountsFetcher = walletAccountsFetcher,
|
||||
expressServiceFetcher = expressServiceFetcher,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
package com.tangem.data.tokens.di
|
||||
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.common.cache.CacheRegistry
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
|
||||
import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository
|
||||
import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository
|
||||
|
|
@ -11,10 +8,7 @@ import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedReposit
|
|||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.token.TokenReceiveWarningActionStore
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.express.ExpressServiceFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
|
||||
|
|
@ -35,29 +29,17 @@ internal object TokensDataModule {
|
|||
@Singleton
|
||||
fun provideCurrenciesRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
cacheRegistry: CacheRegistry,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
expressServiceFetcher: ExpressServiceFetcher,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
|
||||
): CurrenciesRepository {
|
||||
return DefaultCurrenciesRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
cacheRegistry = cacheRegistry,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
expressServiceFetcher = expressServiceFetcher,
|
||||
dispatchers = dispatchers,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,172 +5,33 @@ import com.tangem.blockchain.common.TransactionStatus
|
|||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.data.common.cache.CacheRegistry
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.CryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.common.currency.getTokenId
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.card.CardTypesResolver
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.getSyncStrict
|
||||
import com.tangem.domain.core.error.DataError
|
||||
import com.tangem.domain.express.ExpressServiceFetcher
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.account.DerivationIndex
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.models.wallet.requireColdWallet
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
||||
import com.tangem.domain.tokens.model.FeePaidCurrency
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency
|
||||
|
||||
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
|
||||
internal class DefaultCurrenciesRepository(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val cacheRegistry: CacheRegistry,
|
||||
private val expressServiceFetcher: ExpressServiceFetcher,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
) : CurrenciesRepository {
|
||||
|
||||
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
|
||||
|
||||
override fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
|
||||
return channelFlow {
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
|
||||
if (userWallet.isMultiCurrency) {
|
||||
getMultiCurrencyWalletCurrenciesUpdates(userWalletId).collect(::send)
|
||||
} else {
|
||||
val currencies = getSingleCurrencyWalletWithCardCurrencies(userWalletId)
|
||||
|
||||
send(currencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSingleCurrencyWalletPrimaryCurrency(
|
||||
userWalletId: UserWalletId,
|
||||
refresh: Boolean,
|
||||
): CryptoCurrency {
|
||||
return withContext(dispatchers.io) {
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
userWallet.requireColdWallet()
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
|
||||
|
||||
val currency = cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(
|
||||
userWallet = userWallet,
|
||||
)
|
||||
|
||||
fetchExpressAssetsByNetworkIds(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencies = listOf(currency),
|
||||
refresh = refresh,
|
||||
)
|
||||
|
||||
currency
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSingleCurrencyWalletWithCardCurrencies(
|
||||
userWalletId: UserWalletId,
|
||||
refresh: Boolean,
|
||||
): List<CryptoCurrency> {
|
||||
return withContext(dispatchers.io) {
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
val scanResponse = userWallet.requireColdWallet().scanResponse
|
||||
|
||||
val currencies = if (scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
|
||||
cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = userWallet)
|
||||
} else {
|
||||
cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet)
|
||||
.run {
|
||||
listOf(this)
|
||||
}
|
||||
}
|
||||
|
||||
fetchExpressAssetsByNetworkIds(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencies = currencies,
|
||||
refresh = refresh,
|
||||
)
|
||||
|
||||
currencies
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSingleCurrencyWalletWithCardCurrency(
|
||||
userWalletId: UserWalletId,
|
||||
id: CryptoCurrency.ID,
|
||||
): CryptoCurrency {
|
||||
return withContext(dispatchers.io) {
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
userWallet.requireColdWallet()
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
|
||||
|
||||
val currency = cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(
|
||||
userWallet = userWallet,
|
||||
)
|
||||
.find { it.id == id }
|
||||
requireNotNull(currency) { "Unable to find currency with provided ID: $id" }
|
||||
fetchExpressAssetsByNetworkIds(userWallet, listOf(currency))
|
||||
currency
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
|
||||
return channelFlow {
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
|
||||
|
||||
getMultiCurrencyWalletCurrencies(userWallet)
|
||||
.onEach { send(it) }
|
||||
.launchIn(scope = this + dispatchers.io)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkCoin(
|
||||
userWalletId: UserWalletId,
|
||||
networkId: Network.ID,
|
||||
derivationPath: Network.DerivationPath,
|
||||
): CryptoCurrency.Coin {
|
||||
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
|
||||
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
|
||||
)
|
||||
.orEmpty()
|
||||
.find { currency ->
|
||||
currency is CryptoCurrency.Coin &&
|
||||
currency.network.id.rawId == networkId.rawId &&
|
||||
currency.network.derivationPath == derivationPath
|
||||
} as? CryptoCurrency.Coin
|
||||
?: error("Unable to find coin for network ID: $networkId")
|
||||
}
|
||||
|
||||
override suspend fun isSendBlockedByPendingTransactions(
|
||||
userWalletId: UserWalletId,
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
|
|
@ -276,70 +137,4 @@ internal class DefaultCurrenciesRepository(
|
|||
val blockchain = Blockchain.fromNetworkId(network.backendId)
|
||||
return blockchain?.isNetworkFeeZero() == true
|
||||
}
|
||||
|
||||
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver? {
|
||||
return (userWalletsListRepository.getSyncStrict(userWalletId) as? UserWallet.Cold)?.cardTypesResolver
|
||||
}
|
||||
|
||||
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
|
||||
return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens ->
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = storedTokens,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchExpressAssetsByNetworkIds(
|
||||
userWallet: UserWallet,
|
||||
cryptoCurrencies: List<CryptoCurrency>,
|
||||
refresh: Boolean = false,
|
||||
) {
|
||||
val tokens = cryptoCurrencies.mapTo(hashSetOf()) { currency ->
|
||||
val tokenCurrency = currency as? CryptoCurrency.Token
|
||||
ExpressAsset.ID(
|
||||
networkId = currency.network.backendId,
|
||||
contractAddress = tokenCurrency?.contractAddress,
|
||||
)
|
||||
}
|
||||
cacheRegistry.invokeOnExpire(
|
||||
key = getAssetsCacheKey(userWallet.walletId),
|
||||
skipCache = refresh,
|
||||
block = {
|
||||
coroutineScope {
|
||||
launch { expressServiceFetcher.fetch(userWallet, tokens) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getAssetsCacheKey(userWalletId: UserWalletId): String = "assets_cache_key_${userWalletId.stringValue}"
|
||||
|
||||
private fun ensureIsCorrectUserWallet(userWallet: UserWallet, isMultiCurrencyWalletExpected: Boolean) {
|
||||
val userWalletId = userWallet.walletId
|
||||
|
||||
val message = when {
|
||||
!userWallet.isMultiCurrency && isMultiCurrencyWalletExpected -> {
|
||||
"Multi currency wallet expected, but single currency wallet was found: $userWalletId"
|
||||
}
|
||||
|
||||
userWallet.isMultiCurrency && !isMultiCurrencyWalletExpected -> {
|
||||
"Single currency wallet expected, but multi currency wallet was found: $userWalletId"
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
val error = DataError.UserWalletError.WrongUserWallet(message)
|
||||
|
||||
Timber.e(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSavedUserTokensResponse(key: UserWalletId): Flow<UserTokensResponse> {
|
||||
return userTokensResponseStore.get(userWalletId = key).filterNotNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,13 @@
|
|||
package com.tangem.data.tokens
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.express.ExpressServiceFetcher
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
|
||||
import com.tangem.test.core.assertEither
|
||||
import com.tangem.test.core.assertEitherRight
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
|
|
@ -25,13 +23,11 @@ internal class AccountListCryptoCurrenciesFetcherTest {
|
|||
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
|
||||
private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxUnitFun = true)
|
||||
private val expressServiceFetcher: ExpressServiceFetcher = mockk()
|
||||
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||
|
||||
private val fetcher = AccountListCryptoCurrenciesFetcher(
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
walletAccountsFetcher = walletAccountsFetcher,
|
||||
expressServiceFetcher = expressServiceFetcher,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
|
|
@ -43,7 +39,7 @@ internal class AccountListCryptoCurrenciesFetcherTest {
|
|||
@Test
|
||||
fun `returns failure if wallet is not multi-currency`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)
|
||||
val mockUserWallet = mockk<UserWallet> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns false
|
||||
|
|
@ -68,7 +64,7 @@ internal class AccountListCryptoCurrenciesFetcherTest {
|
|||
@Test
|
||||
fun `returns accounts if wallet is multi-currency`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)
|
||||
val mockUserWallet = mockk<UserWallet> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
|
|
@ -79,7 +75,6 @@ internal class AccountListCryptoCurrenciesFetcherTest {
|
|||
|
||||
every { userWalletsListRepository.userWallets } returns userWalletsFlow
|
||||
coEvery { walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } returns response
|
||||
coEvery { expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet()) } returns Unit.right()
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
|
@ -90,14 +85,13 @@ internal class AccountListCryptoCurrenciesFetcherTest {
|
|||
coVerify(ordering = Ordering.SEQUENCE) {
|
||||
userWalletsListRepository.userWallets
|
||||
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
|
||||
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns error if walletAccountsFetcher returns error`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)
|
||||
val mockUserWallet = mockk<UserWallet> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
|
|
|
|||
24
data/tokensync/build.gradle.kts
Normal file
24
data/tokensync/build.gradle.kts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.tokensync"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.data.tokensync.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
internal class DefaultTokenSyncStore(
|
||||
private val persistenceStore: DataStore<List<UserTokensResponse.Token>>,
|
||||
) : TokenSyncStore {
|
||||
|
||||
override suspend fun get(): List<UserTokensResponse.Token> {
|
||||
return persistenceStore.data.firstOrNull().orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun append(tokens: List<UserTokensResponse.Token>) {
|
||||
persistenceStore.updateData { existing ->
|
||||
(existing + tokens).distinct()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
persistenceStore.updateData { emptyList() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.data.tokensync.store
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
|
||||
interface TokenSyncStore {
|
||||
|
||||
suspend fun get(): List<UserTokensResponse.Token>
|
||||
|
||||
suspend fun append(tokens: List<UserTokensResponse.Token>)
|
||||
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.tangem.data.tokensync.store
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.listTypes
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TokenSyncStoreFactory @Inject constructor(
|
||||
@NetworkMoshi private val moshi: Moshi,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val appScope: AppCoroutineScope,
|
||||
) {
|
||||
|
||||
private val stores = ConcurrentHashMap<String, TokenSyncStore>()
|
||||
|
||||
fun provide(userWalletId: UserWalletId): TokenSyncStore {
|
||||
val userWalletStringId = userWalletId.formatted()
|
||||
return stores.computeIfAbsent(userWalletStringId) {
|
||||
DefaultTokenSyncStore(
|
||||
persistenceStore = createPersistenceStore(
|
||||
fileName = "token_sync_$userWalletStringId",
|
||||
types = listTypes<UserTokensResponse.Token>(),
|
||||
defaultValue = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> createPersistenceStore(
|
||||
fileName: String,
|
||||
types: java.lang.reflect.ParameterizedType,
|
||||
defaultValue: T,
|
||||
): DataStore<T> = DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
moshi = moshi,
|
||||
types = types,
|
||||
defaultValue = defaultValue,
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = fileName) },
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
private fun UserWalletId.formatted(): String = stringValue.lowercase()
|
||||
}
|
||||
|
|
@ -2,14 +2,16 @@ package com.tangem.data.pay
|
|||
|
||||
import com.tangem.common.card.FirmwareVersion
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.TangemPayEligibilityType
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isLocked
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.pay.TangemPayEligibilityManager
|
||||
import com.tangem.domain.pay.model.TangemPayEntryPoint
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -17,57 +19,59 @@ import kotlinx.coroutines.sync.withLock
|
|||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val coroutineScope: AppCoroutineScope,
|
||||
private val onboardingRepository: OnboardingRepository,
|
||||
) : TangemPayEligibilityManager {
|
||||
|
||||
private var cachedEligibleWallets: List<UserWalletData>? = null
|
||||
private var cachedEligibleWallets: Map<TangemPayEntryPoint?, List<UserWalletData>> = emptyMap()
|
||||
private var eligibleWalletsDeferred: Deferred<List<UserWalletData>>? = null
|
||||
private val loadMutex = Mutex()
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
init {
|
||||
resetDataWhenWalletsUpdate()
|
||||
}
|
||||
|
||||
override suspend fun getEligibleWallets(shouldExcludePaeraCustomers: Boolean): List<UserWallet> {
|
||||
return getUserWalletsData().mapNotNull {
|
||||
override suspend fun getEligibleWallets(
|
||||
shouldExcludePaeraCustomers: Boolean,
|
||||
entryPoint: TangemPayEntryPoint?,
|
||||
): List<UserWallet> {
|
||||
return getUserWalletsData(entryPoint = entryPoint).mapNotNull {
|
||||
if (!it.isPaeraCustomer || !shouldExcludePaeraCustomers) it.userWallet else null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPossibleWalletsIds(shouldExcludePaeraCustomers: Boolean): List<UserWalletId> {
|
||||
return getPossibleWalletsForTangemPay().addPaeraCustomersData().mapNotNull {
|
||||
return getPossibleWalletsForTangemPay(entryPoint = null).addPaeraCustomersData().mapNotNull {
|
||||
if (!it.isPaeraCustomer || !shouldExcludePaeraCustomers) it.userWallet.walletId else null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTangemPayAvailability(): Boolean {
|
||||
return onboardingRepository.checkCustomerEligibility()
|
||||
override suspend fun getTangemPayAvailability(entryPoint: TangemPayEntryPoint): Boolean {
|
||||
val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty {
|
||||
onboardingRepository.checkCustomerEligibility()
|
||||
}
|
||||
val type = entryPoint.toEligibilityType()
|
||||
return eligibility.any { it == type }
|
||||
.also { isEligible -> if (!isEligible) reset() }
|
||||
}
|
||||
|
||||
override suspend fun isPaeraCustomerForAnyWallet(): Boolean {
|
||||
return getUserWalletsData().any { it.isPaeraCustomer }
|
||||
override suspend fun isPaeraCustomerForAnyWallet(entryPoint: TangemPayEntryPoint): Boolean {
|
||||
return getUserWalletsData(entryPoint = entryPoint).any { it.isPaeraCustomer }
|
||||
}
|
||||
|
||||
private suspend fun getUserWalletsData(): List<UserWalletData> {
|
||||
cachedEligibleWallets?.let { return it }
|
||||
private suspend fun getUserWalletsData(entryPoint: TangemPayEntryPoint?): List<UserWalletData> {
|
||||
cachedEligibleWallets[entryPoint]?.let { return it }
|
||||
|
||||
return loadMutex.withLock {
|
||||
cachedEligibleWallets?.let { return it }
|
||||
cachedEligibleWallets[entryPoint]?.let { return it }
|
||||
eligibleWalletsDeferred?.let { return it.await() }
|
||||
|
||||
coroutineScope {
|
||||
val deferred = async {
|
||||
if (!checkTangemPayEligibility()) {
|
||||
emptyList()
|
||||
} else {
|
||||
getPossibleWalletsForTangemPay()
|
||||
.addPaeraCustomersData()
|
||||
.also { cachedEligibleWallets = it }
|
||||
}
|
||||
getPossibleWalletsForTangemPay(entryPoint = entryPoint)
|
||||
.addPaeraCustomersData()
|
||||
.also { cachedEligibleWallets += mapOf(entryPoint to it) }
|
||||
}
|
||||
eligibleWalletsDeferred = deferred
|
||||
try {
|
||||
|
|
@ -79,8 +83,8 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun getPossibleWalletsForTangemPay(): List<UserWallet> {
|
||||
if (!checkTangemPayEligibility()) {
|
||||
private suspend fun getPossibleWalletsForTangemPay(entryPoint: TangemPayEntryPoint?): List<UserWallet> {
|
||||
if (!checkTangemPayEligibility(entryPoint = entryPoint)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
|
@ -122,13 +126,25 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
}
|
||||
|
||||
override fun reset() {
|
||||
cachedEligibleWallets = null
|
||||
cachedEligibleWallets = emptyMap()
|
||||
eligibleWalletsDeferred?.cancel()
|
||||
eligibleWalletsDeferred = null
|
||||
}
|
||||
|
||||
private suspend fun checkTangemPayEligibility(): Boolean {
|
||||
return onboardingRepository.getCustomerEligibility() || onboardingRepository.checkCustomerEligibility()
|
||||
private suspend fun checkTangemPayEligibility(entryPoint: TangemPayEntryPoint?): Boolean {
|
||||
val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty {
|
||||
onboardingRepository.checkCustomerEligibility()
|
||||
}
|
||||
return if (entryPoint == null) {
|
||||
eligibility.isNotEmpty()
|
||||
} else {
|
||||
eligibility.any { it == entryPoint.toEligibilityType() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) {
|
||||
TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER
|
||||
TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS
|
||||
}
|
||||
|
||||
private data class UserWalletData(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
|||
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
|
||||
import com.tangem.domain.pay.TangemPayEligibilityManager
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
|
|
@ -38,8 +39,6 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -112,6 +111,7 @@ internal interface TangemPayDataModule {
|
|||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
scope: AppCoroutineScope,
|
||||
): PaymentAccountStatusesStore {
|
||||
return PaymentAccountStatusesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
|
|
@ -122,9 +122,9 @@ internal interface TangemPayDataModule {
|
|||
defaultValue = emptyMap(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
|
||||
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
|
||||
scope = scope,
|
||||
),
|
||||
dispatchers = dispatchers,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.tangem.datasource.api.pay.models.response.OrderResponse
|
|||
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.TangemPayEligibilityType
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
|
@ -211,19 +212,20 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun checkCustomerEligibility(): Boolean {
|
||||
override suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType> {
|
||||
val response = requestHelper.performWithStaticToken {
|
||||
tangemPayApi.checkCustomerEligibility()
|
||||
tangemPayApi.getEligibilityChannels()
|
||||
}.getOrNull()
|
||||
|
||||
val isAvailable = response?.result?.isTangemPayAvailable == true
|
||||
tangemPayStorage.storeTangemPayEligibility(eligibility = isAvailable)
|
||||
val channels = response?.result?.channels
|
||||
val eligibility = channels.orEmpty().toSet()
|
||||
tangemPayStorage.storeTangemPayEligibility(eligibility = eligibility)
|
||||
|
||||
return isAvailable
|
||||
return eligibility.map(TangemPayEligibilityType::fromString)
|
||||
}
|
||||
|
||||
override suspend fun getCustomerEligibility(): Boolean {
|
||||
return tangemPayStorage.getTangemPayEligibility()
|
||||
override suspend fun getCustomerEligibility(): List<TangemPayEligibilityType> {
|
||||
return tangemPayStorage.getTangemPayEligibility().map(TangemPayEligibilityType::fromString)
|
||||
}
|
||||
|
||||
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
|
|||
import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status
|
||||
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.SetPinResult
|
||||
import com.tangem.domain.pay.model.TangemPayCardBalance
|
||||
|
|
@ -45,9 +46,9 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
|
|||
private val storage: TangemPayStorage,
|
||||
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
|
||||
private val errorConverter: TangemPayErrorConverter,
|
||||
private val pollingScope: AppCoroutineScope,
|
||||
) : TangemPayCardDetailsRepository {
|
||||
|
||||
private val pollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val pollingJobs = mutableMapOf<String, Job>()
|
||||
private val storePollingMutex = Mutex()
|
||||
|
||||
|
|
|
|||
|
|
@ -22,11 +22,9 @@ import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
|
|||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.extensions.addHexPrefix
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -43,6 +41,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
|
||||
private const val TAG = "TangemPaySwapRepository"
|
||||
private const val MAX_POLLING_ATTEMPTS = 6
|
||||
|
||||
private data class PollingKey(val userWalletId: String, val orderId: String)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -54,9 +53,9 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
|
|||
private val tangemPayStorage: TangemPayStorage,
|
||||
private val swapRepository: SwapRepository,
|
||||
private val orderRepository: CustomerOrderRepository,
|
||||
private val withdrawPollingScope: AppCoroutineScope,
|
||||
) : TangemPayWithdrawRepository {
|
||||
|
||||
private val pollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val pollingJobs = mutableMapOf<PollingKey, Job>()
|
||||
private val pollingMutex = Mutex()
|
||||
|
||||
|
|
@ -139,7 +138,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
|
|||
orderId: String,
|
||||
exchangeData: TangemPayWithdrawExchangeState,
|
||||
) {
|
||||
pollingScope.launch {
|
||||
withdrawPollingScope.launch {
|
||||
startWithdrawOrderPolling(userWallet, orderId, exchangeData)
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +153,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
|
|||
pollingMutex.withLock {
|
||||
if (pollingJobs.containsKey(key)) return@withLock
|
||||
|
||||
val pollingJob = pollingScope.launch {
|
||||
val pollingJob = withdrawPollingScope.launch {
|
||||
try {
|
||||
var attemptCount = 0
|
||||
while (isActive && attemptCount < MAX_POLLING_ATTEMPTS) {
|
||||
|
|
@ -194,7 +193,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
|
|||
}
|
||||
|
||||
private fun stopPolling(userWalletId: String, orderId: String) {
|
||||
pollingScope.launch {
|
||||
withdrawPollingScope.launch {
|
||||
pollingMutex.withLock {
|
||||
val key = PollingKey(userWalletId, orderId)
|
||||
pollingJobs[key]?.cancel()
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ import androidx.datastore.core.DataStore
|
|||
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.PaymentAccountStatus
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
|
@ -28,11 +26,9 @@ internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatu
|
|||
internal class PaymentAccountStatusesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
|
||||
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val scope: AppCoroutineScope,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import com.tangem.data.walletconnect.respond.WcRespondService
|
|||
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.data.walletconnect.utils.WcNetworksConverter
|
||||
import com.tangem.data.walletconnect.utils.WcScope
|
||||
import com.tangem.datasource.di.SdkMoshi
|
||||
import com.tangem.datasource.local.walletconnect.WalletConnectStore
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
|
|
@ -33,6 +32,7 @@ import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
|
|||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -79,8 +79,8 @@ internal object WalletConnectDataModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun sdkDelegate(wcScope: WcScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate(
|
||||
scope = wcScope,
|
||||
fun sdkDelegate(appScope: AppCoroutineScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate(
|
||||
scope = appScope,
|
||||
store = store,
|
||||
)
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ internal object WalletConnectDataModule {
|
|||
wcNetworksConverter: WcNetworksConverter,
|
||||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
analytics: AnalyticsEventHandler,
|
||||
wcScope: WcScope,
|
||||
appScope: AppCoroutineScope,
|
||||
): DefaultWcSessionsManager {
|
||||
return DefaultWcSessionsManager(
|
||||
store = store,
|
||||
|
|
@ -101,7 +101,7 @@ internal object WalletConnectDataModule {
|
|||
getWallets = getWallets,
|
||||
wcNetworksConverter = wcNetworksConverter,
|
||||
analytics = analytics,
|
||||
scope = wcScope,
|
||||
scope = appScope,
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
)
|
||||
}
|
||||
|
|
@ -110,10 +110,6 @@ internal object WalletConnectDataModule {
|
|||
@Singleton
|
||||
fun wcSessionsManager(default: DefaultWcSessionsManager): WcSessionsManager = default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcScope(dispatchers: CoroutineDispatcherProvider): WcScope = WcScope(dispatchers)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.data.walletconnect.network.ethereum
|
||||
|
||||
import arrow.core.getOrElse
|
||||
import com.domain.blockaid.models.transaction.CheckTransactionResult
|
||||
import com.domain.blockaid.models.transaction.SimulationResult
|
||||
import com.domain.blockaid.models.transaction.simultation.ApproveInfo
|
||||
|
|
@ -17,16 +18,17 @@ import com.tangem.blockchainsdk.utils.toBlockchain
|
|||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.common.extensions.hexToBytes
|
||||
import com.tangem.data.common.currency.getCoinId
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCryptoCurrency
|
||||
import com.tangem.domain.account.supplier.SingleAccountListSupplier
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcApprovedAmount
|
||||
import com.tangem.domain.walletconnect.model.WcEthTransactionParams
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class WcEthTxHelper @Inject constructor(
|
||||
private val getSingleCryptoCurrency: GetSingleCryptoCurrencyStatusUseCase,
|
||||
private val singleAccountListSupplier: SingleAccountListSupplier,
|
||||
private val ethSpecificFee: GetEthSpecificFeeUseCase,
|
||||
) {
|
||||
|
||||
|
|
@ -34,10 +36,19 @@ internal class WcEthTxHelper @Inject constructor(
|
|||
val gasLimit = txParams.gas?.hexToBigInteger() ?: return null
|
||||
val gasPrice = txParams.gasPrice?.hexToBigInteger()
|
||||
val coinId = getCoinId(network, network.toBlockchain().toCoinId())
|
||||
val currency = getSingleCryptoCurrency.invokeMultiWalletSync(userWallet.walletId, coinId)
|
||||
.map { it.currency }
|
||||
.getOrNull() ?: return null
|
||||
return ethSpecificFee(userWallet, currency, gasLimit, gasPrice)
|
||||
|
||||
val currency = singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
|
||||
.getCryptoCurrency(currencyId = coinId, network = network)
|
||||
.getOrElse {
|
||||
return null
|
||||
}
|
||||
|
||||
return ethSpecificFee(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrency = currency,
|
||||
gasLimit = gasLimit,
|
||||
gasPrice = gasPrice,
|
||||
)
|
||||
.map { it.minimum }
|
||||
.getOrNull()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,13 +41,17 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
|
|||
|
||||
@Suppress("LongMethod")
|
||||
override operator fun invoke(): Flow<WcPairState> {
|
||||
val (uri: String, source: WcPairRequest.Source) = pairRequest
|
||||
return flow {
|
||||
Timber.tag(WC_TAG).i("start pair flow $pairRequest")
|
||||
analytics.send(WcAnalyticEvents.NewPairInitiated(source))
|
||||
analytics.send(
|
||||
WcAnalyticEvents.NewPairInitiated(
|
||||
source = pairRequest.source,
|
||||
screen = pairRequest.screen,
|
||||
),
|
||||
)
|
||||
emit(WcPairState.Loading)
|
||||
|
||||
val pairResult = sdkDelegate.pair(uri)
|
||||
val pairResult = sdkDelegate.pair(pairRequest.uri)
|
||||
.onLeft {
|
||||
Timber.tag(WC_TAG).e(it, "Failed to call pair $pairRequest")
|
||||
analytics.send(
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import arrow.core.right
|
|||
import com.reown.walletkit.client.Wallet
|
||||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.data.walletconnect.utils.WC_TAG
|
||||
import com.tangem.data.walletconnect.utils.WcScope
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
import com.tangem.datasource.local.walletconnect.WalletConnectStore
|
||||
import com.tangem.data.walletconnect.utils.getDappOriginUrl
|
||||
import com.tangem.domain.walletconnect.model.WcPairError
|
||||
import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed
|
||||
import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
|
|
@ -22,7 +22,7 @@ import kotlin.coroutines.resume
|
|||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal class WcPairSdkDelegate(
|
||||
private val scope: WcScope,
|
||||
private val scope: AppCoroutineScope,
|
||||
private val store: WalletConnectStore,
|
||||
) : WcSdkObserver {
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.reown.walletkit.client.WalletKit
|
|||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.data.walletconnect.utils.WC_TAG
|
||||
import com.tangem.data.walletconnect.utils.WcNetworksConverter
|
||||
import com.tangem.data.walletconnect.utils.WcScope
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
|
||||
import com.tangem.datasource.local.walletconnect.WalletConnectStore
|
||||
|
|
@ -22,6 +21,7 @@ import com.tangem.domain.walletconnect.model.WcSession
|
|||
import com.tangem.domain.walletconnect.model.WcSessionDTO
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
|
@ -40,7 +40,7 @@ internal class DefaultWcSessionsManager(
|
|||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val wcNetworksConverter: WcNetworksConverter,
|
||||
private val analytics: AnalyticsEventHandler,
|
||||
private val scope: WcScope,
|
||||
private val scope: AppCoroutineScope,
|
||||
) : WcSessionsManager, WcSdkObserver {
|
||||
|
||||
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
package com.tangem.data.walletconnect.utils
|
||||
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
internal class WcScope(
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : CoroutineScope {
|
||||
|
||||
override val coroutineContext: CoroutineContext = SupervisorJob() + dispatchers.io
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import com.tangem.blockchain.extensions.Result
|
|||
import com.tangem.blockchain.extensions.SimpleResult
|
||||
import com.tangem.blockchain.nft.models.NFTAsset
|
||||
import com.tangem.blockchain.nft.models.NFTCollection
|
||||
import com.tangem.blockchain.tokenbalance.models.TokenBalance
|
||||
import com.tangem.blockchain.transactionhistory.models.TransactionHistoryRequest
|
||||
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
||||
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
||||
|
|
@ -769,6 +770,17 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
|||
return blockchain.getNFTExploreUrl(assetIdentifier)
|
||||
}
|
||||
|
||||
override suspend fun getTokenBalances(userWalletId: UserWalletId, network: Network): List<TokenBalance> {
|
||||
val blockchain = network.toBlockchain()
|
||||
val walletManager = getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = blockchain,
|
||||
derivationPath = network.derivationPath.value,
|
||||
) ?: return emptyList()
|
||||
val address = walletManager.wallet.address
|
||||
return walletManager.getTokenBalances(address)
|
||||
}
|
||||
|
||||
override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean {
|
||||
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network)
|
||||
val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ internal class SdkTransactionHistoryItemConverter(
|
|||
is SdkTransactionHistoryItem.TransactionType.ContractMethodName,
|
||||
-> mapToInteractionAddressType(destinationType = destinationType)
|
||||
|
||||
is SdkTransactionHistoryItem.TransactionType.SolanaStakingTransactionType.Stake -> {
|
||||
transactionType.validatorAddress?.let {
|
||||
TxInfo.InteractionAddressType.Validator(address = it)
|
||||
}
|
||||
}
|
||||
is SdkTransactionHistoryItem.TransactionType.TronStakingTransactionType.VoteWitnessContract -> {
|
||||
TxInfo.InteractionAddressType.Validator(address = transactionType.validatorAddress)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ internal class SdkTransactionTypeConverter(
|
|||
is TransactionType.Transfer -> {
|
||||
TxInfo.TransactionType.Transfer
|
||||
}
|
||||
is TransactionType.SolanaStakingTransactionType.Stake -> {
|
||||
TxInfo.TransactionType.Staking.Stake
|
||||
}
|
||||
is TransactionType.SolanaStakingTransactionType.Unstake -> {
|
||||
TxInfo.TransactionType.Staking.Unstake
|
||||
}
|
||||
is TransactionType.SolanaStakingTransactionType.Withdraw -> {
|
||||
TxInfo.TransactionType.Staking.Withdraw
|
||||
}
|
||||
is TransactionType.TronStakingTransactionType.FreezeBalanceV2Contract -> {
|
||||
TxInfo.TransactionType.Staking.Stake
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ internal class DefaultWalletsRepository(
|
|||
override suspend fun associateWallets(applicationId: String, wallets: List<UserWallet>) =
|
||||
withContext(dispatchers.io) {
|
||||
val associateApplicationIdWithWallets: suspend () -> ApiResponse<Unit> = {
|
||||
tangemTechApi.associateApplicationIdWithWalletsV2(
|
||||
tangemTechApi.associateApplicationIdWithWallets(
|
||||
applicationId = applicationId,
|
||||
body = AssociateApplicationIdWithWalletsBody(
|
||||
walletIds = wallets.map { it.walletId.stringValue }.distinct(),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.wallets.hot
|
|||
|
||||
import com.tangem.common.core.TangemSdkError
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.settings.repositories.LegacySettingsRepository
|
||||
|
|
@ -11,10 +12,7 @@ import com.tangem.domain.wallets.repository.WalletsRepository
|
|||
import com.tangem.hot.sdk.TangemHotSdk
|
||||
import com.tangem.hot.sdk.exception.WrongPasswordException
|
||||
import com.tangem.hot.sdk.model.*
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
|
@ -27,11 +25,9 @@ class DefaultHotWalletAccessor @Inject constructor(
|
|||
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
|
||||
private val walletsRepository: WalletsRepository,
|
||||
private val legacySettingsRepository: LegacySettingsRepository,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val scope: AppCoroutineScope,
|
||||
) : HotWalletAccessor {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
private val contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap()
|
||||
|
||||
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
|
||||
|
|
@ -233,7 +229,6 @@ class DefaultHotWalletAccessor @Inject constructor(
|
|||
this is TangemSdkError.AuthenticationLockout ||
|
||||
this is TangemSdkError.AuthenticationUnavailable ||
|
||||
this is TangemSdkError.AuthenticationAlreadyInProgress ||
|
||||
this is TangemSdkError.AuthenticationNotInitialized ||
|
||||
this is TangemSdkError.AuthenticationPermanentLockout
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ class DefaultWalletsRepositoryTest {
|
|||
inner class AssociateWallets {
|
||||
|
||||
@Test
|
||||
fun `should convert and send to API V2`() = runTest {
|
||||
fun `should convert and send to associateApplicationIdWithWallets`() = runTest {
|
||||
// Arrange
|
||||
val applicationId = "test_app_id"
|
||||
val wallet1Id = "1234567890abcdef"
|
||||
|
|
@ -202,7 +202,7 @@ class DefaultWalletsRepositoryTest {
|
|||
)
|
||||
|
||||
coEvery {
|
||||
tangemTechApi.associateApplicationIdWithWalletsV2(eq(applicationId), any())
|
||||
tangemTechApi.associateApplicationIdWithWallets(eq(applicationId), any())
|
||||
} returns ApiResponse.Success(Unit)
|
||||
|
||||
// Act
|
||||
|
|
@ -210,7 +210,7 @@ class DefaultWalletsRepositoryTest {
|
|||
|
||||
// Assert
|
||||
coVerify(exactly = 1) {
|
||||
tangemTechApi.associateApplicationIdWithWalletsV2(
|
||||
tangemTechApi.associateApplicationIdWithWallets(
|
||||
applicationId = eq(applicationId),
|
||||
body = match { body ->
|
||||
body.walletIds.size == 2 &&
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue