Updated on 2026-08-14

This commit is contained in:
Tangem 2025-05-12 18:56:56 +03:00
commit 99c026d495
51 changed files with 627 additions and 329 deletions

View file

@ -37,6 +37,7 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
@ -311,6 +312,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
TangemApiServiceLogging.addInterceptors(
createNetworkLoggingInterceptor(),
ChuckerInterceptor(this),
NetworkLogsSaveInterceptor(appLogsStore),
)
}

View file

@ -32,6 +32,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -64,7 +65,7 @@ internal class DefaultCardSdkProvider @Inject constructor(
if (BuildConfig.TESTER_MENU_ENABLED) {
appPreferencesStore.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.map {
when (it[ApiConfig.ID.TangemCardSdk.name]) {
when (it[ApiConfig.ID.Attestation.name]) {
ApiEnvironment.DEV,
ApiEnvironment.STAGE,
-> false
@ -73,6 +74,7 @@ internal class DefaultCardSdkProvider @Inject constructor(
-> true
}
}
.distinctUntilChanged()
.onEach { isProd ->
holder?.let {
it.sdk.config.isTangemAttestationProdEnv = isProd
@ -170,7 +172,7 @@ internal class DefaultCardSdkProvider @Inject constructor(
config = config.apply {
isNewOnlineAttestationEnabled = cardSdkFeatureToggles.isNewAttestationEnabled
val apiConfig = apiConfigsManager.getEnvironmentConfig(id = ApiConfig.ID.TangemCardSdk)
val apiConfig = apiConfigsManager.getEnvironmentConfig(id = ApiConfig.ID.Attestation)
isTangemAttestationProdEnv = apiConfig.environment == ApiEnvironment.PROD
},
)

View file

@ -39,6 +39,7 @@ fun ExpressProvider(
val hapticFeedback = LocalHapticFeedback.current
val context = LocalContext.current
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),

View file

@ -19,16 +19,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.R
import com.tangem.common.ui.expressStatus.state.ExpressLinkUM
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemState
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemUM
import com.tangem.common.ui.expressStatus.state.ExpressStatusUM
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.persistentListOf
@ -53,6 +53,7 @@ fun ExpressStatusBlock(state: ExpressStatusUM, modifier: Modifier = Modifier) {
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing16),
) {
@ -77,8 +78,14 @@ fun ExpressStatusBlock(state: ExpressStatusUM, modifier: Modifier = Modifier) {
.padding(end = TangemTheme.dimens.spacing2),
)
Text(
text = link.text.resolveReference(),
style = TangemTheme.typography.body2,
modifier = Modifier.align(Alignment.CenterVertically),
text = resourceReference(
id = R.string.express_transaction_id,
formatArgs = wrappedList(link.text),
).resolveReference(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}

View file

@ -25,7 +25,7 @@ sealed class ApiConfig {
TangemTech,
StakeKit,
TangemVisa,
TangemCardSdk,
Attestation,
BlockAid,
}
@ -35,7 +35,7 @@ sealed class ApiConfig {
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is TangemVisa -> ID.TangemVisa
is TangemCardSdk -> ID.TangemCardSdk
is Attestation -> ID.Attestation
is BlockAid -> ID.BlockAid
}
}

View file

@ -1,11 +1,11 @@
package com.tangem.datasource.api.common.config
/**
* Tangem API [ApiConfig] that used in CardSDK
* Attestation [ApiConfig] that used in CardSDK
*
[REDACTED_AUTHOR]
*/
internal class TangemCardSdk : ApiConfig() {
internal class Attestation : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD

View file

@ -16,27 +16,27 @@ interface TangemExpressApi {
@POST("assets")
suspend fun getAssets(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Body body: AssetsRequestBody,
): ApiResponse<List<Asset>>
@POST("pairs")
suspend fun getPairs(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Body body: PairsRequestBody,
): ApiResponse<List<SwapPair>>
@GET("providers")
suspend fun getProviders(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
): ApiResponse<List<ExchangeProvider>>
@GET("exchange-quote")
suspend fun getExchangeQuote(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Query("fromContractAddress") fromContractAddress: String,
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@ -51,7 +51,7 @@ interface TangemExpressApi {
@GET("exchange-data")
suspend fun getExchangeData(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Query("fromContractAddress") fromContractAddress: String,
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@ -71,14 +71,14 @@ interface TangemExpressApi {
@GET("exchange-status")
suspend fun getExchangeStatus(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<ExchangeStatusResponse>
@POST("exchange-sent")
suspend fun exchangeSent(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Body body: ExchangeSentRequestBody,
): ApiResponse<ExchangeSentResponseBody>
}

View file

@ -17,38 +17,38 @@ interface OnrampApi {
@GET("currencies")
suspend fun getCurrencies(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
): ApiResponse<List<OnrampCurrencyDTO>>
@GET("countries")
suspend fun getCountries(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
): ApiResponse<List<OnrampCountryDTO>>
@GET("country-by-ip")
suspend fun getCountryByIp(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
): ApiResponse<OnrampCountryDTO>
@GET("payment-methods")
suspend fun getPaymentMethods(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
): ApiResponse<List<PaymentMethodDTO>>
@POST("onramp-pairs")
suspend fun getPairs(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Body body: OnrampPairsRequest,
): ApiResponse<List<OnrampPairDTO>>
@GET("onramp-quote")
suspend fun getQuote(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Query("fromCurrencyCode") fromCurrencyCode: String,
@Query("fromPrecision") fromPrecision: Int,
@Query("toContractAddress") toContractAddress: String,
@ -83,7 +83,7 @@ interface OnrampApi {
@GET("onramp-status")
suspend fun getStatus(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<OnrampStatusResponse>
}

View file

@ -49,5 +49,5 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideTangemCardSdkConfig(): ApiConfig = TangemCardSdk()
fun provideAttestationConfig(): ApiConfig = Attestation()
}

View file

@ -11,12 +11,12 @@ object ExpressUtils {
private const val BATCH_ID_CHANGENOW = "BB000013"
private const val BATCH_ID_PARTNER = "AF990015"
fun getRefCode(userWallet: UserWallet, appPreferencesStore: AppPreferencesStore): String {
fun getRefCode(userWallet: UserWallet, appPreferencesStore: AppPreferencesStore): String? {
return when {
isRing(userWallet, appPreferencesStore) -> "ring"
isChangeNow(userWallet) -> "ChangeNow"
isPartner(userWallet) -> "partner"
else -> ""
else -> null
}
}

View file

@ -25,7 +25,7 @@ private const val JSON_INDENT_SPACES = 4
*
[REDACTED_AUTHOR]
*/
internal class NetworkLogsSaveInterceptor(
class NetworkLogsSaveInterceptor(
private val appLogsStore: AppLogsStore,
) : Interceptor {

View file

@ -86,7 +86,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
is TangemTech -> createTangemTechModel()
is StakeKit -> createStakeKitModel()
is TangemVisa -> createVisaModel()
is TangemCardSdk -> createTangemCardSdkModel()
is Attestation -> createAttestationModel()
is BlockAid -> createBlockAidSdkModel()
}
}
@ -189,9 +189,9 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
)
}
private fun createTangemCardSdkModel(): Model {
private fun createAttestationModel(): Model {
return Model(
id = ApiConfig.ID.TangemCardSdk,
id = ApiConfig.ID.Attestation,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem-tech.com/",

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
@ -76,7 +77,7 @@ fun InputRowBestRate(
text = subtitle.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
modifier = Modifier.padding(top = 2.dp),
)
}
SpacerWMax()

View file

@ -0,0 +1,30 @@
package com.tangem.core.ui.extensions
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.semantics.Role
import com.tangem.core.ui.utils.MultipleClickPreventer
/**
* Clickable modifier with debounce multiple click in a short period of time
*/
fun Modifier.clickableSingle(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit,
) = composed {
val multipleEventsCutter = remember { MultipleClickPreventer.get() }
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = { multipleEventsCutter.processEvent { onClick() } },
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() },
)
}

View file

@ -2,6 +2,9 @@ package com.tangem.core.ui.utils
import android.os.SystemClock
/**
* Debounce multiple events (e.i. clicks, etc) in a short period of time
*/
interface MultipleClickPreventer {
fun processEvent(event: () -> Unit)
@ -24,4 +27,9 @@ private class DefaultMultipleClickPreventer : MultipleClickPreventer {
companion object {
private const val TIME_BETWEEN_CLICK_IN_MS = 500L
}
}
}
/**
* Debounce multiple events (e.i. clicks, etc) in a short period of time
*/
fun singleEvent(event: () -> Unit) = MultipleClickPreventer.get().processEvent(event)

View file

@ -1,11 +1,26 @@
package com.tangem.data.networks.multi
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.catch
import arrow.core.raise.ensure
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.networks.store.NetworksStatusesStoreV2
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -19,26 +34,53 @@ import javax.inject.Inject
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
excludedBlockchains: ExcludedBlockchains,
private val networksStatusesStore: NetworksStatusesStoreV2,
private val userWalletsStore: UserWalletsStore,
private val appPreferencesStore: AppPreferencesStore,
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
private val dispatchers: CoroutineDispatcherProvider,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
) : MultiNetworkStatusFetcher {
override suspend fun invoke(params: MultiNetworkStatusFetcher.Params): Either<Throwable, Unit> = either {
private val responseCurrenciesFactory by lazy { ResponseCryptoCurrenciesFactory(excludedBlockchains) }
override suspend fun invoke(params: MultiNetworkStatusFetcher.Params) = eitherOn(dispatchers.default) {
// Optimization!
// Every singleNetworkStatusFetcher with applyRefresh as true will refresh every network in the store.
// So if we update all networks at once, it will be more efficient.
networksStatusesStore.refresh(userWalletId = params.userWalletId, networks = params.networks)
val userWallet = catch(
block = { userWalletsStore.getSyncStrict(key = params.userWalletId) },
catch = {
networksStatusesStore.storeError(userWalletId = params.userWalletId, networks = params.networks)
raise(it)
},
)
val isNotSingleWallet = with(userWallet.scanResponse.cardTypesResolver) {
isMultiwalletAllowed() || isSingleWalletWithToken()
}
ensure(isNotSingleWallet) {
networksStatusesStore.storeError(userWalletId = params.userWalletId, networks = params.networks)
IllegalStateException("User wallet is not multi-currency")
}
val networksCurrencies = createCurrencies(userWallet = userWallet, networks = params.networks)
val result = coroutineScope {
params.networks
.map {
async {
singleNetworkStatusFetcher(
params = SingleNetworkStatusFetcher.Params(
params = SingleNetworkStatusFetcher.Params.Prepared(
userWalletId = params.userWalletId,
network = it,
applyRefresh = false,
addedNetworkCurrencies = networksCurrencies,
),
)
}
@ -52,4 +94,31 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
IllegalStateException("Failed to fetch network statuses")
}
}
private suspend fun createCurrencies(userWallet: UserWallet, networks: Set<Network>): Set<CryptoCurrency> {
val blockchains = networks.map { Blockchain.fromNetworkId(networkId = it.backendId) }
// multi-currency wallet
if (userWallet.isMultiCurrency) return getMultiWalletCurrencies(userWallet = userWallet, networks = networks)
// check if the blockchain of single-currency wallet is the same as network
val cardBlockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain()
if (!blockchains.contains(cardBlockchain)) return emptySet()
return cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet.scanResponse).toSet()
}
private suspend fun getMultiWalletCurrencies(userWallet: UserWallet, networks: Set<Network>): Set<CryptoCurrency> {
val response = appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
key = PreferencesKeys.getUserTokensKey(userWallet.walletId.stringValue),
) ?: return emptySet()
return responseCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token ->
networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath }
},
scanResponse = userWallet.scanResponse,
)
.toSet()
}
}

View file

@ -34,14 +34,17 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
private val networkStatusFactory = NetworkStatusFactory()
override suspend fun invoke(params: SingleNetworkStatusFetcher.Params) = Either.catchOn(dispatchers.default) {
if (params.applyRefresh) {
networksStatusesStore.refresh(userWalletId = params.userWalletId, network = params.network)
}
val networkCurrencies = when (params) {
is SingleNetworkStatusFetcher.Params.Prepared -> params.addedNetworkCurrencies
is SingleNetworkStatusFetcher.Params.Simple -> {
networksStatusesStore.refresh(userWalletId = params.userWalletId, network = params.network)
val networkCurrencies = cardCryptoCurrencyFactory.create(
userWalletId = params.userWalletId,
network = params.network,
)
cardCryptoCurrencyFactory.create(
userWalletId = params.userWalletId,
network = params.network,
)
}
}
val result = withContext(dispatchers.io) {
walletManagersFacade.update(
@ -61,11 +64,20 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
val statusValue = status.value
if (statusValue is NetworkStatus.Unreachable) {
networksStatusesStore.storeError(
val prevStatus = networksStatusesStore.getSyncOrNull(
userWalletId = params.userWalletId,
network = params.network,
value = statusValue,
)
if (prevStatus?.value is NetworkStatus.MissedDerivation) {
networksStatusesStore.storeUnreachableStatus(userWalletId = params.userWalletId, value = status)
} else {
networksStatusesStore.storeError(
userWalletId = params.userWalletId,
network = params.network,
value = statusValue,
)
}
} else {
networksStatusesStore.storeSuccess(userWalletId = params.userWalletId, value = status)
}

View file

@ -55,6 +55,12 @@ internal class DefaultNetworksStatusesStoreV2(
return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] }
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId, network: Network): SimpleNetworkStatus? {
val simpleStatusId = SimpleNetworkStatus.Id(network = network)
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)?.firstOrNull { it.id == simpleStatusId }
}
override suspend fun refresh(userWalletId: UserWalletId, network: Network) {
refresh(userWalletId = userWalletId, networks = setOf(network))
}
@ -105,6 +111,28 @@ internal class DefaultNetworksStatusesStoreV2(
)
}
override suspend fun storeError(userWalletId: UserWalletId, networks: Set<Network>) {
updateInRuntime(
userWalletId = userWalletId,
networks = networks,
ifNotFound = ::createUnreachableStatus,
update = { status ->
status.copy(value = status.value.copySealed(source = StatusSource.ONLY_CACHE))
},
)
}
override suspend fun storeUnreachableStatus(userWalletId: UserWalletId, value: NetworkStatus) {
if (value.value !is NetworkStatus.Unreachable) {
val message = "Use storeError method to save unreachable status"
Timber.d(message)
error(message)
}
storeInRuntime(userWalletId = userWalletId, status = value)
}
private suspend fun updateInRuntime(
userWalletId: UserWalletId,
networks: Set<Network>,

View file

@ -12,6 +12,9 @@ internal interface NetworksStatusesStoreV2 {
/** Get statuses by [userWalletId] */
fun get(userWalletId: UserWalletId): Flow<Set<SimpleNetworkStatus>>
/** Get status of [network] by [userWalletId] synchronously or null */
suspend fun getSyncOrNull(userWalletId: UserWalletId, network: Network): SimpleNetworkStatus?
/** Refresh status of [network] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, network: Network)
@ -27,4 +30,10 @@ internal interface NetworksStatusesStoreV2 {
* If [value] is null, default unreachable status will be stored.
*/
suspend fun storeError(userWalletId: UserWalletId, network: Network, value: NetworkStatus.Unreachable? = null)
/** Store unreachable [value] by [userWalletId] */
suspend fun storeUnreachableStatus(userWalletId: UserWalletId, value: NetworkStatus)
/** Store error for [networks] by [userWalletId] */
suspend fun storeError(userWalletId: UserWalletId, networks: Set<Network>)
}

View file

@ -1,133 +1,119 @@
package com.tangem.data.networks.multi
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.store.NetworksStatusesStoreV2
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.wallets.models.UserWalletId
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiNetworkStatusFetcherTest {
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk()
private val networksStatusesStore: NetworksStatusesStoreV2 = mockk(relaxed = true)
private val fetcher = DefaultMultiNetworkStatusFetcher(
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
networksStatusesStore = networksStatusesStore,
)
@Test
fun `fetch networks statuses successfully`() = runTest {
val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
val ethParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.first(),
applyRefresh = false,
)
val stellarParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.last(),
applyRefresh = false,
)
coEvery { singleNetworkStatusFetcher(ethParams) } returns Unit.right()
coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right()
val actual = fetcher(params)
coVerify {
networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
singleNetworkStatusFetcher(ethParams)
singleNetworkStatusFetcher(stellarParams)
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch networks statuses failure if one of them fails`() = runTest {
val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
val ethParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.first(),
applyRefresh = false,
)
val stellarParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.last(),
applyRefresh = false,
)
val ethException = IllegalStateException("eth")
coEvery { singleNetworkStatusFetcher(ethParams) } returns ethException.left()
coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right()
val actual = fetcher(params)
coVerify {
networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
singleNetworkStatusFetcher(ethParams)
singleNetworkStatusFetcher(stellarParams)
}
val expected = IllegalStateException("Failed to fetch network statuses")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch networks statuses failure if all of them fails`() = runTest {
val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
val ethParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.first(),
applyRefresh = false,
)
val stellarParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.last(),
applyRefresh = false,
)
coEvery { singleNetworkStatusFetcher(ethParams) } returns IllegalStateException("eth").left()
coEvery { singleNetworkStatusFetcher(stellarParams) } returns IllegalStateException("stellar").left()
val actual = fetcher(params)
coVerify {
networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
singleNetworkStatusFetcher(ethParams)
singleNetworkStatusFetcher(stellarParams)
}
val expected = IllegalStateException("Failed to fetch network statuses")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
private companion object {
val userWalletId = UserWalletId("011")
val ethereumAndStellar = MockCryptoCurrencyFactory().ethereumAndStellar.map { it.network }.toSet()
}
}
// internal class DefaultMultiNetworkStatusFetcherTest {
//
// private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk()
// private val networksStatusesStore: NetworksStatusesStoreV2 = mockk(relaxed = true)
//
// private val fetcher = DefaultMultiNetworkStatusFetcher(
// singleNetworkStatusFetcher = singleNetworkStatusFetcher,
// networksStatusesStore = networksStatusesStore,
// )
//
// @Test
// fun `fetch networks statuses successfully`() = runTest {
// val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
//
// val ethParams = SingleNetworkStatusFetcher.Params(
// userWalletId = userWalletId,
// network = ethereumAndStellar.first(),
// applyRefresh = false,
// )
//
// val stellarParams = SingleNetworkStatusFetcher.Params(
// userWalletId = userWalletId,
// network = ethereumAndStellar.last(),
// applyRefresh = false,
// )
//
// coEvery { singleNetworkStatusFetcher(ethParams) } returns Unit.right()
// coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right()
//
// val actual = fetcher(params)
//
// coVerify {
// networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
// singleNetworkStatusFetcher(ethParams)
// singleNetworkStatusFetcher(stellarParams)
// }
//
// Truth.assertThat(actual.isRight()).isTrue()
// }
//
// @Test
// fun `fetch networks statuses failure if one of them fails`() = runTest {
// val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
//
// val ethParams = SingleNetworkStatusFetcher.Params(
// userWalletId = userWalletId,
// network = ethereumAndStellar.first(),
// applyRefresh = false,
// )
//
// val stellarParams = SingleNetworkStatusFetcher.Params(
// userWalletId = userWalletId,
// network = ethereumAndStellar.last(),
// applyRefresh = false,
// )
//
// val ethException = IllegalStateException("eth")
// coEvery { singleNetworkStatusFetcher(ethParams) } returns ethException.left()
// coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right()
//
// val actual = fetcher(params)
//
// coVerify {
// networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
// singleNetworkStatusFetcher(ethParams)
// singleNetworkStatusFetcher(stellarParams)
// }
//
// val expected = IllegalStateException("Failed to fetch network statuses")
//
// Truth.assertThat(actual.isLeft()).isTrue()
// Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
// Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
// }
//
// @Test
// fun `fetch networks statuses failure if all of them fails`() = runTest {
// val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar)
//
// val ethParams = SingleNetworkStatusFetcher.Params(
// userWalletId = userWalletId,
// network = ethereumAndStellar.first(),
// applyRefresh = false,
// )
//
// val stellarParams = SingleNetworkStatusFetcher.Params(
// userWalletId = userWalletId,
// network = ethereumAndStellar.last(),
// applyRefresh = false,
// )
//
// coEvery { singleNetworkStatusFetcher(ethParams) } returns IllegalStateException("eth").left()
// coEvery { singleNetworkStatusFetcher(stellarParams) } returns IllegalStateException("stellar").left()
//
// val actual = fetcher(params)
//
// coVerify {
// networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
// singleNetworkStatusFetcher(ethParams)
// singleNetworkStatusFetcher(stellarParams)
// }
//
// val expected = IllegalStateException("Failed to fetch network statuses")
//
// Truth.assertThat(actual.isLeft()).isTrue()
// Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
// Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
// }
//
// private companion object {
// val userWalletId = UserWalletId("011")
// val ethereumAndStellar = MockCryptoCurrencyFactory().ethereumAndStellar.map { it.network }.toSet()
// }
// }

View file

@ -81,38 +81,10 @@ internal class DefaultSingleNetworkStatusFetcherTest {
Truth.assertThat(actual.leftOrNull()).isEqualTo(exception)
}
@Test
fun `fetch network status if applyRefresh is false`() = runTest {
val params = createParams(applyRefresh = false)
coEvery { cardCryptoCurrencyFactory.create(params.userWalletId, params.network) } returns listOf(ethereum)
val result = UpdateWalletManagerResult.MissedDerivation
coEvery { walletManagersFacade.update(params.userWalletId, params.network, emptySet()) } returns result
val actual = fetcher(params)
coVerifyOrder {
cardCryptoCurrencyFactory.create(params.userWalletId, params.network)
walletManagersFacade.update(params.userWalletId, params.network, emptySet())
networksStatusesStore.storeSuccess(
userWalletId = params.userWalletId,
value = NetworkStatus(params.network, NetworkStatus.MissedDerivation),
)
}
coVerify(inverse = true) {
networksStatusesStore.refresh(userWalletId = any(), network = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
private fun createParams(applyRefresh: Boolean = true): SingleNetworkStatusFetcher.Params {
return SingleNetworkStatusFetcher.Params(
private fun createParams(): SingleNetworkStatusFetcher.Params {
return SingleNetworkStatusFetcher.Params.Simple(
userWalletId = UserWalletId("011"),
network = ethereum.network,
applyRefresh = applyRefresh,
)
}

View file

@ -280,6 +280,51 @@ internal class NetworkStatusesStoreUpdateMethodsTest {
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<NetworkStatusDM>>())
}
@Test
fun `store error for networks if runtime store is empty`() = runTest {
store.storeError(userWalletId = userWalletId, networks = networks)
val runtimeExpected = mapOf(
userWalletId.stringValue to setOf(
SimpleNetworkStatus(
id = SimpleNetworkStatus.Id(networks.first()),
value = NetworkStatus.Unreachable(address = null),
),
SimpleNetworkStatus(
id = SimpleNetworkStatus.Id(networks.last()),
value = NetworkStatus.Unreachable(address = null),
),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<NetworkStatusDM>>())
}
@Test
fun `store error for networks if runtime store contains status with this network`() = runTest {
val status = MockNetworkStatusFactory.createVerified(networks.first()).toSimple()
runtimeStore.store(
value = mapOf(userWalletId.stringValue to setOf(status)),
)
store.storeError(userWalletId = userWalletId, networks = networks)
val runtimeExpected = mapOf(
userWalletId.stringValue to setOf(
status.copy(value = status.value.copySealed(source = StatusSource.ONLY_CACHE)),
SimpleNetworkStatus(
id = SimpleNetworkStatus.Id(networks.last()),
value = NetworkStatus.Unreachable(address = null),
),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<NetworkStatusDM>>())
}
private companion object {
val userWalletId = UserWalletId(stringValue = "011")

View file

@ -372,7 +372,7 @@ internal class DefaultOnrampRepository(
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
).takeUnless { it.isEmpty() },
),
).bind()
},
onError = { e ->

View file

@ -10,6 +10,7 @@ import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -53,7 +54,11 @@ internal class DefaultMultiQuoteFetcher @Inject constructor(
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
val response = safeApiCallWithTimeout(
call = { tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() },
call = {
withContext(dispatchers.io) {
tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind()
}
},
onError = { error -> throw error },
)

View file

@ -1,10 +1,13 @@
package com.tangem.domain.core.utils
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import com.tangem.domain.core.lce.Lce
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import kotlin.experimental.ExperimentalTypeInference
/**
* [Flow] of [Either]
@ -47,4 +50,15 @@ suspend inline fun <R> Either.Companion.catchOn(
context = dispatcher,
block = { catch { function() } },
)
}
@JvmName("eitherWithDispatch")
@OptIn(ExperimentalTypeInference::class)
suspend inline fun <Error, A> eitherOn(
dispatcher: CoroutineDispatcher,
@BuilderInference noinline block: suspend Raise<Error>.() -> A,
): Either<Error, A> {
return withContext(dispatcher) {
either { block() }
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.networks.single
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
@ -11,16 +12,18 @@ import com.tangem.domain.wallets.models.UserWalletId
*/
interface SingleNetworkStatusFetcher : FlowFetcher<SingleNetworkStatusFetcher.Params> {
/**
* Params
*
* @property userWalletId wallet id
* @property network network
* @property applyRefresh flag that determines whether to apply refresh (see DefaultMultiNetworkStatusFetcher)
*/
data class Params(
val userWalletId: UserWalletId,
val network: Network,
val applyRefresh: Boolean = true,
)
/** Params */
sealed interface Params {
val userWalletId: UserWalletId
val network: Network
data class Simple(override val userWalletId: UserWalletId, override val network: Network) : Params
data class Prepared(
override val userWalletId: UserWalletId,
override val network: Network,
val addedNetworkCurrencies: Set<CryptoCurrency>,
) : Params
}
}

View file

@ -5,12 +5,12 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
class TokenOnrampAnalyticsEvent(
sealed class TokenOnrampAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Onramp", event, params) {
class OnrampStatusOpened(tokenSymbol: String, provider: String, fiatCurrency: String) : TokenScreenAnalyticsEvent(
class OnrampStatusOpened(tokenSymbol: String, provider: String, fiatCurrency: String) : TokenOnrampAnalyticsEvent(
event = "Onramp Status Opened",
params = mapOf(
TOKEN_PARAM to tokenSymbol,
@ -20,7 +20,7 @@ class TokenOnrampAnalyticsEvent(
)
class OnrampStatusChanged(tokenSymbol: String, status: String, provider: String, fiatCurrency: String) :
TokenScreenAnalyticsEvent(
TokenOnrampAnalyticsEvent(
event = "Onramp Status",
params = mapOf(
TOKEN_PARAM to tokenSymbol,
@ -30,12 +30,12 @@ class TokenOnrampAnalyticsEvent(
),
)
data object GoToProvider : TokenScreenAnalyticsEvent(event = "Button - Go To Provider")
data object GoToProvider : TokenOnrampAnalyticsEvent(event = "Button - Go To Provider")
data class NoticeKYC(
val tokenSymbol: String,
val provider: String,
) : TokenScreenAnalyticsEvent(
) : TokenOnrampAnalyticsEvent(
event = "Notice - KYC",
params = mapOf(
TOKEN_PARAM to tokenSymbol,

View file

@ -119,7 +119,7 @@ class FetchCurrencyStatusUseCase(
private suspend fun Raise<CurrencyStatusError>.fetchNetworkStatus(userWalletId: UserWalletId, network: Network) {
singleNetworkStatusFetcher(
params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network),
params = SingleNetworkStatusFetcher.Params.Simple(userWalletId = userWalletId, network = network),
)
.mapLeft { CurrencyStatusError.DataError(it) }
.bind()

View file

@ -38,7 +38,7 @@ class UpdateDelayedNetworkStatusUseCase(
private suspend fun Raise<CurrencyStatusError>.fetchNetworkStatus(userWalletId: UserWalletId, network: Network) {
singleNetworkStatusFetcher(
params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network),
params = SingleNetworkStatusFetcher.Params.Simple(userWalletId = userWalletId, network = network),
)
.mapLeft(CurrencyStatusError::DataError)
.bind()

View file

@ -82,7 +82,7 @@ class SendTransactionUseCase(
return sendResult
.onRight {
singleNetworkStatusFetcher(
params = SingleNetworkStatusFetcher.Params(
params = SingleNetworkStatusFetcher.Params.Simple(
userWalletId = userWallet.walletId,
network = network,
),

View file

@ -26,6 +26,7 @@ internal class OnrampAmountStateFactory(
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
private val needApplyFCARestrictions: Provider<Boolean>,
) {
private val onrampAmountFieldChangeConverter = OnrampAmountFieldChangeConverter(
@ -122,7 +123,10 @@ internal class OnrampAmountStateFactory(
.filterNot { it == bestProvider }
.any { it is OnrampQuote.Data }
val isBestProvider = selectedQuote == bestProvider && isMultipleQuotes && isOtherQuotesHasData
val isBestProvider = selectedQuote == bestProvider &&
isMultipleQuotes &&
isOtherQuotesHasData &&
!needApplyFCARestrictions()
return currentState.copy(
providerBlockState = selectedQuote.toProviderBlockState(isBestProvider),
@ -154,7 +158,7 @@ internal class OnrampAmountStateFactory(
paymentMethod = providerResult.paymentMethod,
providerId = providerResult.provider.id,
providerName = providerResult.provider.info.name,
isBestRate = isBestRate,
isBestRate = isBestRate && !needApplyFCARestrictions(),
onClick = onrampIntents::openProviders,
termsOfUseLink = providerResult.provider.info.termsOfUseLink,
privacyPolicyLink = providerResult.provider.info.privacyPolicyLink,

View file

@ -20,6 +20,9 @@ import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.*
@ -35,6 +38,7 @@ import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -53,6 +57,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private val messageSender: UiMessageSender,
private val urlOpener: UrlOpener,
getWalletsUseCase: GetWalletsUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
paramsContainer: ParamsContainer,
) : Model(), OnrampIntents {
@ -70,6 +75,7 @@ internal class OnrampMainComponentModel @Inject constructor(
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
cryptoCurrency = params.cryptoCurrency,
needApplyFCARestrictions = Provider { userCountry.needApplyFCARestrictions() },
)
private val _state: MutableStateFlow<OnrampMainComponentUM> = MutableStateFlow(
value = stateFactory.getInitialState(
@ -82,8 +88,12 @@ internal class OnrampMainComponentModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<OnrampMainBottomSheetConfig> = SlotNavigation()
private val lastUpdateState = mutableStateOf<OnrampLastUpdate?>(null)
private var userCountry: UserCountry? = null
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
@ -287,17 +297,6 @@ internal class OnrampMainComponentModel @Inject constructor(
lastUpdateState.value = null
return
}
if (checkLastInputState(quote)) {
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
)
_state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes)
}
}
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) }
}
@ -312,7 +311,7 @@ internal class OnrampMainComponentModel @Inject constructor(
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
// Check if amount, country or currency has changed
return if (checkLastInputState(quoteToCheck)) {
val newQuote = if (checkLastInputState(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampMainComponentUM.Content
@ -331,6 +330,20 @@ internal class OnrampMainComponentModel @Inject constructor(
lastSelectedQuote
}
}
newQuote?.let { updateProvider(newQuote, quotes) }
return newQuote
}
private fun updateProvider(quote: OnrampQuote, quotes: List<OnrampQuote>) {
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
)
_state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes)
}
}
private fun onRetryQuotes() {
@ -387,7 +400,8 @@ internal class OnrampMainComponentModel @Inject constructor(
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
else -> { /* no-op */ }
else -> { /* no-op */
}
}
}
}

View file

@ -52,7 +52,7 @@ internal class SelectProviderModel @Inject constructor(
private val getOnrampSelectedPaymentMethodUseCase: GetOnrampSelectedPaymentMethodUseCase,
private val getOnrampProviderWithQuoteUseCase: GetOnrampProviderWithQuoteUseCase,
private val saveSelectedPaymentMethod: OnrampSaveSelectedPaymentMethod,
private val getUserCountryUseCase: GetUserCountryUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
paramsContainer: ParamsContainer,
) : Model() {

View file

@ -5,21 +5,38 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.onramp.redirect.model.OnrampRedirectModel
import com.tangem.features.onramp.redirect.ui.OnrampRedirectContent
import com.tangem.features.onramp.success.OnrampSuccessScreenTrigger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
internal class DefaultOnrampRedirectComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: OnrampRedirectComponent.Params,
private val onrampSuccessScreenTrigger: OnrampSuccessScreenTrigger,
) : OnrampRedirectComponent, AppComponentContext by appComponentContext {
private val model: OnrampRedirectModel = getOrCreateModel(params)
init {
lifecycle.subscribe(
onResume = {
// On reopen check if redirect happened. If so trigger close action
if (model.latestOnrampTransaction != null) {
componentScope.launch {
onrampSuccessScreenTrigger.triggerOnrampSuccess(false)
}
}
},
)
}
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = params.onBack)

View file

@ -46,7 +46,7 @@ internal class OnrampRedirectModel @Inject constructor(
private val params: OnrampRedirectComponent.Params = paramsContainer.require()
private val selectedUserWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
private var latestOnrampTransaction: OnrampTransaction? = null
var latestOnrampTransaction: OnrampTransaction? = null
val state = OnrampRedirectUM(
topBarConfig = OnrampRedirectTopBarUM(
@ -100,10 +100,12 @@ internal class OnrampRedirectModel @Inject constructor(
val replaceOnrampScreens = appRouter.stack
.filterNot { it is AppRoute.Onramp }
.toMutableList()
latestOnrampTransaction = null
replaceOnrampScreens.add(AppRoute.OnrampSuccess(latestTxId))
appRouter.replaceAll(*replaceOnrampScreens.toTypedArray())
} else {
// Close redirect screen and show last onramp state
latestOnrampTransaction = null
params.onBack()
}
}

View file

@ -213,7 +213,7 @@ internal class SetOnrampSuccessContentConverter(
else -> if (externalTxId != null) {
ExpressLinkUM.Content(
icon = R.drawable.ic_copy_24,
text = resourceReference(R.string.express_transaction_id, wrappedList(externalTxId)),
text = stringReference(externalTxId),
onClick = { onCopyClick(externalTxId) },
)
} else {

View file

@ -20,6 +20,7 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.GetCryptoCurrencyUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.onramp.component.OnrampSuccessComponent
import com.tangem.features.onramp.impl.R
@ -64,6 +65,7 @@ internal class OnrampSuccessComponentModel @Inject constructor(
}
override fun goToProviderClick(providerLink: String) {
analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider)
urlOpener.openUrl(providerLink)
}

View file

@ -14,7 +14,7 @@ internal sealed class CommonSendRoute : Route {
@Serializable
data object Confirm : CommonSendRoute() {
override val isEditMode: Boolean = false
override val isEditMode: Boolean = true
}
@Serializable

View file

@ -4,19 +4,19 @@ sealed class PredefinedValues {
data object Empty : PredefinedValues()
sealed class Content : PredefinedValues() {
abstract val amount: String
abstract val address: String
abstract val amount: String?
abstract val memo: String?
data class Deeplink(
override val amount: String,
override val amount: String?,
override val address: String,
override val memo: String?,
val transactionId: String,
) : Content()
data class QrCode(
override val amount: String,
override val amount: String?,
override val address: String,
override val memo: String?,
) : Content()

View file

@ -2,7 +2,6 @@ package com.tangem.features.send.v2.common.ui
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -26,10 +25,12 @@ import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.features.send.v2.send.ui.state.ButtonsUM
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.singleEvent
import com.tangem.features.send.v2.common.ui.state.NavigationUM
import com.tangem.features.send.v2.send.ui.state.ButtonsUM
@Composable
internal fun SendNavigationButtons(navigationUM: NavigationUM, modifier: Modifier = Modifier) {
@ -70,7 +71,7 @@ private fun SendNavigationButton(navigationUM: NavigationUM, modifier: Modifier
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(onClick = wrappedNavigationUM.onClick)
.clickableSingle(onClick = wrappedNavigationUM.onClick)
.padding(12.dp),
)
SpacerW12()
@ -110,7 +111,11 @@ private fun SendDoneButtons(pairButtonsUM: ButtonsUM.SecondaryPairButtonsUM?, mo
SecondaryButtonIconStart(
text = wrappedPairButtonsUM.leftText.resolveReference(),
iconResId = wrappedPairButtonsUM.leftIconResId!!,
onClick = wrappedPairButtonsUM.onLeftClick,
onClick = {
singleEvent {
wrappedPairButtonsUM.onLeftClick
}
},
modifier = Modifier.weight(1f),
)
SpacerW12()
@ -118,8 +123,10 @@ private fun SendDoneButtons(pairButtonsUM: ButtonsUM.SecondaryPairButtonsUM?, mo
text = wrappedPairButtonsUM.rightText.resolveReference(),
iconResId = wrappedPairButtonsUM.rightIconResId!!,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
wrappedPairButtonsUM.onRightClick()
singleEvent {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
wrappedPairButtonsUM.onRightClick()
}
},
modifier = Modifier.weight(1f),
)

View file

@ -0,0 +1,28 @@
package com.tangem.features.send.v2.common.utils
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.common.CommonSendRoute
/**
* Workaround to try fix duplicate route crash
*/
internal fun Router.safeNextClick(
currentRoute: CommonSendRoute,
nextRoute: CommonSendRoute,
childStack: Value<ChildStack<CommonSendRoute, ComposableContentComponent>>,
popBack: () -> Unit,
) {
if (currentRoute.isEditMode) {
popBack()
} else {
val isAlreadyInStack = childStack.value.items.any { it.configuration == nextRoute }
if (isAlreadyInStack) {
popTo(nextRoute)
} else {
push(nextRoute)
}
}
}

View file

@ -26,6 +26,7 @@ import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.v2.common.ui.SendContent
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.utils.safeNextClick
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.confirm.SendConfirmComponent
import com.tangem.features.send.v2.send.model.SendModel
@ -139,28 +140,31 @@ internal class DefaultSendComponent @AssistedInject constructor(
CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
}
private fun getDestinationComponent(factoryContext: AppComponentContext, route: CommonSendRoute) =
SendDestinationComponent(
appComponentContext = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = currentRoute.filterIsInstance<CommonSendRoute.Destination>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = analyticCategoryName,
title = resourceReference(R.string.send_recipient_label),
userWalletId = params.userWalletId,
cryptoCurrency = params.currency,
callback = model,
onBackClick = ::onChildBack,
onNextClick = {
if (route.isEditMode) {
onChildBack()
} else {
innerRouter.push(CommonSendRoute.Amount(isEditMode = false))
}
},
),
)
private fun getDestinationComponent(
factoryContext: AppComponentContext,
route: CommonSendRoute,
): SendDestinationComponent = SendDestinationComponent(
appComponentContext = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = currentRoute.filterIsInstance<CommonSendRoute.Destination>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = analyticCategoryName,
title = resourceReference(R.string.send_recipient_label),
userWalletId = params.userWalletId,
cryptoCurrency = params.currency,
callback = model,
onBackClick = ::onChildBack,
onNextClick = {
innerRouter.safeNextClick(
currentRoute = route,
nextRoute = CommonSendRoute.Amount(isEditMode = false),
popBack = ::onChildBack,
childStack = childStack,
)
},
),
)
private fun getAmountComponent(
factoryContext: AppComponentContext,
@ -196,11 +200,12 @@ internal class DefaultSendComponent @AssistedInject constructor(
}
},
onNextClick = {
if (route.isEditMode) {
onChildBack()
} else {
innerRouter.push(CommonSendRoute.Confirm)
}
innerRouter.safeNextClick(
currentRoute = route,
nextRoute = CommonSendRoute.Confirm,
popBack = ::onChildBack,
childStack = childStack,
)
},
),
)

View file

@ -119,8 +119,8 @@ internal class SendModel @Inject constructor(
_uiState.update { it.copy(destinationUM = destinationUM) }
}
override fun onAmountResult(amountUM: AmountState) {
resetPredefinedAmount()
override fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean) {
if (isResetPredefined) resetPredefinedAmount()
_uiState.update { it.copy(amountUM = amountUM) }
}
@ -129,7 +129,6 @@ internal class SendModel @Inject constructor(
}
override fun onResult(sendUM: SendUM) {
resetPredefinedAmount()
_uiState.update { sendUM }
}
@ -161,8 +160,8 @@ internal class SendModel @Inject constructor(
// reset predefined amount
val internalPredefinedValues = predefinedValues
predefinedValues = when (internalPredefinedValues) {
is PredefinedValues.Content.Deeplink -> internalPredefinedValues.copy(amount = "")
is PredefinedValues.Content.QrCode -> internalPredefinedValues.copy(amount = "")
is PredefinedValues.Content.Deeplink -> internalPredefinedValues.copy(amount = null)
is PredefinedValues.Content.QrCode -> internalPredefinedValues.copy(amount = null)
PredefinedValues.Empty -> internalPredefinedValues
}
}

View file

@ -24,6 +24,7 @@ import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.common.ui.SendContent
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.utils.safeNextClick
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent
import com.tangem.features.send.v2.sendnft.model.NFTSendModel
@ -126,28 +127,31 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
else -> getStubComponent()
}
private fun getDestinationComponent(factoryContext: AppComponentContext, route: CommonSendRoute) =
SendDestinationComponent(
appComponentContext = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = currentRouteFlow.filterIsInstance<CommonSendRoute.Destination>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
title = resourceReference(R.string.nft_send),
analyticsCategoryName = analyticsCategoryName,
userWalletId = params.userWalletId,
cryptoCurrency = model.cryptoCurrency,
callback = model,
onBackClick = ::onChildBack,
onNextClick = {
if (route.isEditMode) {
onChildBack()
} else {
innerRouter.push(CommonSendRoute.Confirm)
}
},
),
)
private fun getDestinationComponent(
factoryContext: AppComponentContext,
route: CommonSendRoute,
): SendDestinationComponent = SendDestinationComponent(
appComponentContext = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = currentRouteFlow.filterIsInstance<CommonSendRoute.Destination>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
title = resourceReference(R.string.nft_send),
analyticsCategoryName = analyticsCategoryName,
userWalletId = params.userWalletId,
cryptoCurrency = model.cryptoCurrency,
callback = model,
onBackClick = ::onChildBack,
onNextClick = {
innerRouter.safeNextClick(
currentRoute = route,
nextRoute = CommonSendRoute.Confirm,
popBack = ::onChildBack,
childStack = childStack,
)
},
),
)
private fun getFeeComponent(factoryContext: AppComponentContext): ComposableContentComponent {
val state = model.uiState.value

View file

@ -38,6 +38,6 @@ internal class SendAmountComponent(
}
interface ModelCallback : SendNavigationModelCallback {
fun onAmountResult(amountUM: AmountState)
fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean)
}
}

View file

@ -103,9 +103,9 @@ internal class SendAmountModel @Inject constructor(
)
}
}
val predefinedValues = params.predefinedValues as? PredefinedValues.Content
if (predefinedValues?.amount != null) {
onAmountValueChange(predefinedValues.amount)
val predefinedAmount = (params.predefinedValues as? PredefinedValues.Content)?.amount
if (predefinedAmount != null) {
onAmountValueChange(predefinedAmount)
}
}
@ -220,7 +220,12 @@ internal class SendAmountModel @Inject constructor(
private fun saveResult() {
val params = params as? SendAmountComponentParams.AmountParams ?: return
params.callback.onAmountResult(uiState.value)
val predefinedAmount = (params.predefinedValues as? PredefinedValues.Content.QrCode)?.amount
val enteredAmount = (uiState.value as? AmountState.Data)?.amountTextField?.value
params.callback.onAmountResult(
amountUM = uiState.value,
isResetPredefined = predefinedAmount != enteredAmount,
)
}
private fun configAmountNavigation() {

View file

@ -96,15 +96,15 @@ internal class EnvironmentsTogglesViewModel @Inject constructor(
viewModelScope.launch {
val environment = ApiEnvironment.valueOf(name)
handleIfTangemCardSdkConfig(id = id, environment = environment)
handleIfAttestationConfig(id = id, environment = environment)
mutableApiConfigsManager.changeEnvironment(id = id, environment = environment)
}
}
/** Special logic for [ApiConfig.ID.TangemCardSdk] */
private fun handleIfTangemCardSdkConfig(id: String, environment: ApiEnvironment) {
if (id == ApiConfig.ID.TangemCardSdk.name) {
/** Special logic for [ApiConfig.ID.Attestation] */
private fun handleIfAttestationConfig(id: String, environment: ApiEnvironment) {
if (id == ApiConfig.ID.Attestation.name) {
cardSdkConfigRepository.setTangemApiProdEnvFlag(
flag = when (environment) {
ApiEnvironment.PROD -> true

View file

@ -116,7 +116,10 @@ internal class TokenDetailsOnrampTransactionStateConverter(
}
private fun onProviderClick(externalTxUrl: String?) = if (externalTxUrl != null) {
{ clickIntents.onGoToProviderClick(externalTxUrl) }
{
analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider)
clickIntents.onGoToProviderClick(externalTxUrl)
}
} else {
null
}
@ -219,7 +222,11 @@ internal class TokenDetailsOnrampTransactionStateConverter(
resourceReference(R.string.express_status_bought, wrappedList(cryptoCurrency.name))
}
},
state = getStatusState(OnrampStatus.Status.Paid),
state = when {
this == OnrampStatus.Status.RefundInProgress ||
this == OnrampStatus.Status.Refunded -> ExpressStatusItemState.Error
else -> getStatusState(OnrampStatus.Status.Paid)
},
)
private fun OnrampStatus.Status.getSendingItem() = ExpressStatusItemUM(
@ -251,7 +258,7 @@ internal class TokenDetailsOnrampTransactionStateConverter(
},
state = when {
this == OnrampStatus.Status.RefundInProgress -> ExpressStatusItemState.Active
this == OnrampStatus.Status.Refunded -> ExpressStatusItemState.Error
this == OnrampStatus.Status.Refunded -> ExpressStatusItemState.Done
else -> getStatusState(OnrampStatus.Status.Sending)
},
)
@ -266,6 +273,7 @@ internal class TokenDetailsOnrampTransactionStateConverter(
icon = R.drawable.ic_arrow_top_right_24,
text = resourceReference(R.string.common_go_to_provider),
onClick = {
analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider)
clickIntents.onGoToProviderClick(externalTxUrl)
},
)

View file

@ -24,10 +24,10 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isF
import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel
import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel
import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@ -131,7 +131,10 @@ internal class TokenDetailsSwapTransactionsStateConverter(
notification = notification,
statuses = getStatuses(statusModel.status, hasFailed),
showProviderLink = showProviderLink,
info = tx.info.copy(txExternalUrl = statusModel.txExternalUrl),
info = tx.info.copy(
txExternalId = statusModel.txExternalId,
txExternalUrl = statusModel.txExternalUrl,
),
)
}

View file

@ -14,8 +14,8 @@ import com.tangem.domain.onramp.model.OnrampStatus.Status.*
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsOnrampTransactionStateConverter
import com.tangem.utils.Provider
@ -89,7 +89,13 @@ internal class OnrampStatusFactory @AssistedInject constructor(
},
ifRight = { statusModel ->
sendStatusUpdateAnalytics(onrampTx, statusModel)
onrampTx.copy(activeStatus = statusModel.status)
onrampTx.copy(
activeStatus = statusModel.status,
info = onrampTx.info.copy(
txExternalId = statusModel.externalTxId,
txExternalUrl = statusModel.externalTxUrl,
),
)
},
)
}

View file

@ -427,8 +427,8 @@ fun Blockchain.toCoinId(): String {
Blockchain.Bitrock, Blockchain.BitrockTestnet -> "bitrock"
Blockchain.Sonic, Blockchain.SonicTestnet -> "sonic-3"
Blockchain.ApeChain, Blockchain.ApeChainTestnet -> "apecoin"
Blockchain.Scroll, Blockchain.ScrollTestnet -> "scroll"
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> "zklink"
Blockchain.Scroll, Blockchain.ScrollTestnet -> "scroll-ethereum"
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> "zklink-ethereum"
Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> "pepecoin-network"
}
}