Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-20 15:27:00 +03:00
parent 2a1369fce6
commit 5a99de87fb
29 changed files with 194 additions and 47 deletions

View file

@ -326,6 +326,7 @@ internal class ChildFactory @Inject constructor(
amount = route.amount,
tag = route.tag,
destinationAddress = route.destinationAddress,
entryType = route.entryType.toSendEntryType(),
),
componentFactory = sendComponentFactoryV2,
)
@ -688,3 +689,8 @@ internal class ChildFactory @Inject constructor(
}
}
}
private fun AppRoute.Send.EntryType.toSendEntryType(): SendComponent.EntryType = when (this) {
AppRoute.Send.EntryType.Manual -> SendComponent.EntryType.Manual
AppRoute.Send.EntryType.QR -> SendComponent.EntryType.QR
}

View file

@ -68,13 +68,20 @@ sealed class AppRoute(val path: String) : Route {
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
val entryType: EntryType = EntryType.Manual,
) : AppRoute(
path = "/send/${userWalletId.stringValue}/${currency.id.value}?" +
"&$transactionId" +
"&$amount" +
"&$tag" +
"&$destinationAddress",
)
) {
@Serializable
enum class EntryType {
Manual,
QR,
}
}
@Serializable
data class Details(

View file

@ -52,6 +52,10 @@ sealed class MainScreenAnalyticsEvent(
event = "Notice - Limits Info",
)
class ButtonQrScan : MainScreenAnalyticsEvent(
event = "Button - QR Scan",
)
class ButtonExplore : MainScreenAnalyticsEvent(
event = "Button - Explore",
)

View file

@ -25,7 +25,10 @@ internal class Bip321PaymentUriParser(
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
if (matchingCurrencies.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork,
ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = matchingCoins.firstOrNull()?.network?.name,
),
)
}

View file

@ -24,7 +24,10 @@ internal class Eip681PaymentUriParser(
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
if (matchingCoins.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork,
ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = parsed.chainId?.let { blockchainDataProvider.getBlockchainNameByChainId(it) },
),
)
}
@ -42,7 +45,10 @@ internal class Eip681PaymentUriParser(
PaymentUriParser.ParseResult.Success(result)
} else {
PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork,
ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = matchingCoins.firstOrNull()?.network?.name,
),
)
}
}

View file

@ -43,8 +43,12 @@ internal class QrContentClassifierParser(
)
}
if (blockchainDataProvider.isSupportedAddress(qrCode)) {
return ClassifiedQrContent.Error.UnsupportedNetwork
val supportedBlockchain = blockchainDataProvider.findSupportedBlockchainName(qrCode)
if (supportedBlockchain != null) {
return ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = supportedBlockchain,
)
}
return ClassifiedQrContent.Error.Unrecognized(qrCode)
@ -76,7 +80,8 @@ internal class QrContentClassifierParser(
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean
fun getChainId(network: Network): Long?
fun isSupportedAddress(address: String): Boolean
fun findSupportedBlockchainName(address: String): String?
fun getBlockchainNameByChainId(chainId: Long): String?
}
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
@ -92,12 +97,20 @@ internal class QrContentClassifierParser(
return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull()
}
override fun isSupportedAddress(address: String): Boolean {
override fun findSupportedBlockchainName(address: String): String? {
return Blockchain.entries
.filter { !it.isTestnet() }
.any { blockchain ->
.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
}
}

View file

@ -17,6 +17,7 @@ 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)

View file

@ -17,7 +17,8 @@ internal class QrContentClassifierTest {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
every { getChainId(any()) } returns null
every { isSupportedAddress(any()) } returns false
every { findSupportedBlockchainName(any()) } returns null
every { getBlockchainNameByChainId(any()) } returns null
}
private val paymentUriParser = mockk<PaymentUriParser> {
every { parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.NotRecognized

View file

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

View file

@ -3,28 +3,31 @@ package com.tangem.domain.qrscanning.models
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
sealed class ClassifiedQrContent {
sealed interface ClassifiedQrContent {
data class WalletConnect(val uri: String) : ClassifiedQrContent()
data class WalletConnect(val uri: String) : ClassifiedQrContent
data class PaymentUri(
val address: String,
val amount: BigDecimal?,
val memo: String?,
val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent()
) : ClassifiedQrContent
data class PlainAddress(
val address: String,
val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent()
) : ClassifiedQrContent
sealed class Error : ClassifiedQrContent() {
sealed interface Error : ClassifiedQrContent {
/** QR code not recognized by any parser */
data class Unrecognized(val raw: String) : Error()
data class Unrecognized(val raw: String) : Error
/** Network or token recognized but not available in user's wallet */
data object UnsupportedNetwork : Error()
data class UnsupportedNetwork(
val raw: String,
val blockchain: String?,
) : Error
}
}

View file

@ -8,6 +8,12 @@ data class WcPairRequest(
val uri: String,
val source: Source,
val userWalletId: UserWalletId,
val screen: Screen? = null,
) {
enum class Source { QR, DEEPLINK, CLIPBOARD, ETC }
enum class Screen(val analyticsName: String) {
MAIN("Main Screen"),
WALLET_CONNECT("Wallet Connect Screen"),
}
}

View file

@ -22,16 +22,23 @@ sealed class WcAnalyticEvents(
class ScreenOpened : WcAnalyticEvents(event = "WC Screen Opened"), AppsFlyerIncludedEvent
class NewPairInitiated(source: WcPairRequest.Source) : WcAnalyticEvents(
class NewPairInitiated(
source: WcPairRequest.Source,
screen: WcPairRequest.Screen?,
) : WcAnalyticEvents(
event = "Session Initiated",
params = mapOf(
AnalyticsParam.SOURCE to when (source) {
params = buildMap {
put(
AnalyticsParam.SOURCE,
when (source) {
WcPairRequest.Source.QR -> "QR"
WcPairRequest.Source.DEEPLINK -> "DeepLink"
WcPairRequest.Source.CLIPBOARD -> "Clipboard"
WcPairRequest.Source.ETC -> "etc"
},
),
)
screen?.analyticsName?.let { put(SCREEN, it) }
},
)
class PairButtonConnect(
@ -289,6 +296,7 @@ sealed class WcAnalyticEvents(
const val NETWORKS = "Networks"
const val DOMAIN_VERIFICATION = "Domain Verification"
const val SCREEN = "Screen"
const val WC_CATEGORY_NAME = "Wallet Connect"
}
}

View file

@ -14,9 +14,15 @@ interface SendComponent : ComposableContentComponent {
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
val entryType: EntryType = EntryType.Manual,
val callback: ModelCallback? = null,
)
enum class EntryType {
Manual,
QR,
}
interface Factory : ComponentFactory<Params, SendComponent>
interface ModelCallback {

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
/**
@ -32,11 +33,13 @@ sealed class CommonSendAnalyticEvents(
data class AmountScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val type: SendEntryType = SendEntryType.Manual,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Amount Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
TYPE to type.analyticsName,
),
), AppsFlyerIncludedEvent
@ -95,6 +98,7 @@ sealed class CommonSendAnalyticEvents(
val toDerivationIndex: Int?,
val sendBlockchain: String,
val sendToken: String,
val type: SendEntryType = SendEntryType.Manual,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Confirm Screen Opened",
@ -108,6 +112,7 @@ sealed class CommonSendAnalyticEvents(
"$fromDerivationIndex, $toDerivationIndex",
)
}
put(TYPE, type.analyticsName)
},
), AppsFlyerIncludedEvent
@ -221,6 +226,11 @@ sealed class CommonSendAnalyticEvents(
Confirm,
}
enum class SendEntryType(val analyticsName: String) {
QR("QR"),
Manual("Manually"),
}
enum class CommonSendSource(val analyticsName: String) {
Send("Send"),
Swap("Swap"),

View file

@ -3,6 +3,8 @@ package com.tangem.features.send.v2.networkselection.model
import androidx.compose.runtime.Stable
import com.tangem.common.getTotalCryptoAmount
import com.tangem.common.getTotalFiatAmount
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -44,6 +46,7 @@ import javax.inject.Inject
internal class NetworkSelectionModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
@ -51,6 +54,10 @@ internal class NetworkSelectionModel @Inject constructor(
private val params: NetworkSelectionComponent.Params = paramsContainer.require()
init {
analyticsEventHandler.send(SendAnalyticEvents.ChooseTokenScreenOpened())
}
private val searchQuery = MutableStateFlow("")
private val expandedWallets = MutableStateFlow(
params.walletGroups.firstOrNull()
@ -227,7 +234,15 @@ internal class NetworkSelectionModel @Inject constructor(
text = cryptoAmount.format { crypto(currency) },
isFlickering = isFlickering,
),
onItemClick = { params.onTokenSelected(context.userWalletId, currency) },
onItemClick = {
analyticsEventHandler.send(
SendAnalyticEvents.TokenSelected(
token = currency.symbol,
blockchain = currency.network.name,
),
)
params.onTokenSelected(context.userWalletId, currency)
},
onItemLongClick = null,
)
}

View file

@ -105,6 +105,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
sendToken = fromCurrency.symbol,
fromDerivationIndex = fromDerivationIndex,
toDerivationIndex = null,
type = model.consumeEntryType(),
),
)
if (model.currentRoute.value.isEditMode) {
@ -116,6 +117,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
CommonSendAnalyticEvents.AmountScreenOpened(
categoryName = model.analyticCategoryName,
source = model.analyticsSendSource,
type = model.consumeEntryType(),
),
)
activeComponent.updateState(model.uiState.value.amountUM)

View file

@ -50,6 +50,21 @@ internal sealed class SendAnalyticEvents(
},
), AppsFlyerIncludedEvent
class ChooseTokenScreenOpened : SendAnalyticEvents(
event = "Choose Token Screen Opened",
)
class TokenSelected(
val token: String,
val blockchain: String,
) : SendAnalyticEvents(
event = "Token Selected",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
)
data class ConvertTokenButtonClicked(
val token: String,
val blockchain: String,

View file

@ -105,6 +105,8 @@ internal class SendModel @Inject constructor(
private val params: SendComponent.Params = paramsContainer.require()
private val cryptoCurrency = params.currency
private var isEntryTypeConsumed = false
val analyticCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY
val analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send
@ -264,6 +266,15 @@ internal class SendModel @Inject constructor(
showAlertError()
}
fun consumeEntryType(): CommonSendAnalyticEvents.SendEntryType {
if (isEntryTypeConsumed) return CommonSendAnalyticEvents.SendEntryType.Manual
isEntryTypeConsumed = true
return when (params.entryType) {
SendComponent.EntryType.QR -> CommonSendAnalyticEvents.SendEntryType.QR
SendComponent.EntryType.Manual -> CommonSendAnalyticEvents.SendEntryType.Manual
}
}
private suspend fun prepareTransferTransaction(): Either<Throwable, TransactionData> {
val predefinedValues = predefinedValues
val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value

View file

@ -202,6 +202,7 @@ internal class WalletComponent @AssistedInject constructor(
address = dialogConfig.address,
amount = dialogConfig.amount?.parseBigDecimal(currency.decimals),
tag = dialogConfig.memo,
entryType = AppRoute.Send.EntryType.Manual,
)
},
onDismiss = model.innerWalletRouter.dialogNavigation::dismiss,

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.analytics.models.AnalyticsParam
@ -767,6 +768,7 @@ internal class WalletModel @Inject constructor(
userWalletId = stateHolder.getSelectedWalletId(),
uri = target.uri,
source = source,
screen = WcPairRequest.Screen.MAIN,
),
)
}
@ -777,6 +779,7 @@ internal class WalletModel @Inject constructor(
address = target.address,
amount = target.amount?.parseBigDecimal(target.currency.decimals),
tag = target.memo,
entryType = AppRoute.Send.EntryType.QR,
)
}
is QrSendTarget.Multiple -> {
@ -789,9 +792,17 @@ internal class WalletModel @Inject constructor(
private fun handleQrError(error: ClassifiedQrContent.Error) {
when (error) {
is ClassifiedQrContent.Error.Unrecognized -> {
analyticsEventsHandler.send(
WalletScreenAnalyticsEvent.MainScreen.NoticeUnrecognizedQr(),
)
uiMessageSender.send(WalletAlertUM.qrCodeUnrecognized())
}
is ClassifiedQrContent.Error.UnsupportedNetwork -> {
analyticsEventsHandler.send(
WalletScreenAnalyticsEvent.MainScreen.NoticeNoAvailableTokens(
blockchain = error.blockchain,
),
)
uiMessageSender.send(WalletAlertUM.qrCodeUnsupportedNetwork())
}
}

View file

@ -382,6 +382,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
}
override fun onScanQrClick() {
analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonQrScan())
router.openQrScanner()
}
}

View file

@ -186,6 +186,7 @@ internal class DefaultWalletRouter @Inject constructor(
address: String,
amount: String?,
tag: String?,
entryType: AppRoute.Send.EntryType,
) {
router.push(
AppRoute.Send(
@ -194,6 +195,7 @@ internal class DefaultWalletRouter @Inject constructor(
destinationAddress = address,
amount = amount,
tag = tag,
entryType = entryType,
),
)
}

View file

@ -95,7 +95,14 @@ internal interface InnerWalletRouter {
fun openQrScanner()
/** Open send screen with prefilled destination */
fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?)
fun openSend(
userWalletId: UserWalletId,
currency: CryptoCurrency,
address: String,
amount: String?,
tag: String?,
entryType: AppRoute.Send.EntryType = AppRoute.Send.EntryType.Manual,
)
/** Open network selection bottom sheet for multiple QR matches */
fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple)

View file

@ -167,6 +167,17 @@ sealed class WalletScreenAnalyticsEvent {
class NoticeSeedPhraseSupportButtonDeclined : MainScreen(event = "Button - Support Declined")
class NoticeUnrecognizedQr : MainScreen(
event = "Notice - Unrecognized QR",
)
class NoticeNoAvailableTokens(blockchain: String?) : MainScreen(
event = "Notice - No Available Tokens",
params = buildMap {
if (blockchain != null) put("Blockchain", blockchain)
},
)
// region Referral Promo
class ReferralPromo : MainScreen(event = "Referral Banner")
class ReferralPromoButtonParticipate : MainScreen(event = "Button - Referral Participate")

View file

@ -15,7 +15,6 @@ import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.walletconnect.connections.model.WcPairModel
@ -127,5 +126,7 @@ internal class WcPairComponent(
}
}
data class Params(val userWalletId: UserWalletId, val wcUrl: String, val source: WcPairRequest.Source)
data class Params(
val request: WcPairRequest,
)
}

View file

@ -86,6 +86,7 @@ internal class WcConnectionsModel @Inject constructor(
userWalletId = params.userWalletId,
uri = result.qrCode,
source = source,
screen = WcPairRequest.Screen.WALLET_CONNECT,
),
)
}

View file

@ -29,7 +29,6 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcPairError.Unknown
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.walletconnect.model.WcSessionApprove
import com.tangem.domain.walletconnect.model.WcSessionProposal
import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData
@ -75,13 +74,7 @@ internal class WcPairModel @Inject constructor(
) : Model(), WcPairComponentCallback {
private val params: WcPairComponent.Params = paramsContainer.require()
private val wcPairUseCase = wcPairUseCaseFactory.create(
WcPairRequest(
userWalletId = params.userWalletId,
uri = params.wcUrl,
source = params.source,
),
)
private val wcPairUseCase = wcPairUseCaseFactory.create(params.request)
val stackNavigation = StackNavigation<WcAppInfoRoutes>()
val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
@ -109,7 +102,7 @@ internal class WcPairModel @Inject constructor(
modelScope.launch {
val portfolioBalance = portfolioFetcher.data.first().balances
.firstNotNullOfOrNull { (walletId, balance) ->
if (params.userWalletId == walletId) balance else null
if (params.request.userWalletId == walletId) balance else null
}
if (portfolioBalance == null) {
router.pop()

View file

@ -89,9 +89,7 @@ internal class DefaultWcRoutingComponent @AssistedInject constructor(
appComponentContext = childContext,
portfolioSelectorComponentFactory = portfolioSelectorComponentFactory,
params = WcPairComponent.Params(
userWalletId = config.request.userWalletId,
wcUrl = config.request.uri,
source = config.request.source,
request = config.request,
),
)
is WcInnerRoute.UnsupportedMethodAlert -> AlertsComponent(

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1454"
tangemBlockchainSdk = "develop-1455"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-598"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^