Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-10 15:12:44 +03:00
parent 8a4dbe1b4c
commit 8a79e65e2e
18 changed files with 1099 additions and 101 deletions

View file

@ -22,8 +22,16 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.*
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.wallets.usecase.*
@ -32,6 +40,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWalletAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher
@ -104,6 +113,10 @@ internal class WalletModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletFeatureToggles: WalletFeatureToggles,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val wcPairService: WcPairService,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val classifyQrCodeUseCase: ClassifyQrCodeUseCase,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -134,6 +147,7 @@ internal class WalletModel @Inject constructor(
subscribeToScreenBackgroundState()
subscribeOnPushNotificationsPermission()
subscribeTangemPayOnWalletState()
subscribeToMainScreenQrScanning()
enableNotificationsIfNeeded()
clickIntents.initialize(innerWalletRouter, modelScope)
@ -730,6 +744,65 @@ internal class WalletModel @Inject constructor(
}
}
private fun subscribeToMainScreenQrScanning() {
listenToQrScanningUseCase.listen(SourceType.MAIN_SCREEN)
.getOrElse { emptyFlow() }
.onEach { rawResult -> handleQrResult(rawResult.qrCode, rawResult.resultSource) }
.launchIn(modelScope)
}
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
val userWalletId = stateHolder.getSelectedWalletId()
val currencies = multiWalletCryptoCurrenciesSupplier
.getSyncOrNull(MultiWalletCryptoCurrenciesProducer.Params(userWalletId))
?.toList()
.orEmpty()
val classified = classifyQrCodeUseCase(qrCode, currencies)
when (classified) {
is ClassifiedQrContent.WalletConnect -> {
val source = when (resultSource) {
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
QrResultSource.CAMERA,
QrResultSource.GALLERY,
-> WcPairRequest.Source.QR
}
wcPairService.pair(
WcPairRequest(
userWalletId = userWalletId,
uri = classified.uri,
source = source,
),
)
}
is ClassifiedQrContent.PaymentUri -> {
innerWalletRouter.openSend(
userWalletId = userWalletId,
currency = classified.currency,
address = classified.address,
amount = classified.amount?.toPlainString(),
tag = classified.memo,
)
}
is ClassifiedQrContent.PlainAddress -> {
if (classified.matchingCurrencies.size == 1) {
innerWalletRouter.openSend(
userWalletId = userWalletId,
currency = classified.matchingCurrencies.first(),
address = classified.address,
amount = null,
tag = null,
)
}
// TODO: [REDACTED_TASK_KEY] Network selection bottom sheet for multiple network matches
}
is ClassifiedQrContent.Unknown -> {
// TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes
}
}
}
private fun enableNotificationsIfNeeded() {
modelScope.launch {
val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications()

View file

@ -179,6 +179,24 @@ internal class DefaultWalletRouter @Inject constructor(
router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.MainScreen))
}
override fun openSend(
userWalletId: UserWalletId,
currency: CryptoCurrency,
address: String,
amount: String?,
tag: String?,
) {
router.push(
AppRoute.Send(
userWalletId = userWalletId,
currency = currency,
destinationAddress = address,
amount = amount,
tag = tag,
),
)
}
inner class OrganizeCallbacks : OrganizeTokensComponent.Callback {
override fun onDismiss() {
dialogNavigation.dismiss()

View file

@ -92,4 +92,7 @@ internal interface InnerWalletRouter {
/** Open QR scanner screen */
fun openQrScanner()
/** Open send screen with prefilled destination */
fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?)
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
internal sealed class ClassifiedQrContent {
data class WalletConnect(val uri: String) : ClassifiedQrContent()
data class PaymentUri(
val currency: CryptoCurrency,
val address: String,
val amount: BigDecimal?,
val memo: String?,
) : ClassifiedQrContent()
data class PlainAddress(
val address: String,
val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent()
data class Unknown(val raw: String) : ClassifiedQrContent()
}

View file

@ -0,0 +1,120 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import java.net.URLDecoder
internal class QrContentClassifier(
private val blockchainDataProvider: BlockchainDataProvider,
) {
fun classify(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 }
val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins)
if (paymentUri != null) return paymentUri
val matchingCurrencies = uniqueCoins.filter { coin ->
blockchainDataProvider.validateAddress(coin.network, qrCode)
}
if (matchingCurrencies.isNotEmpty()) {
return ClassifiedQrContent.PlainAddress(
address = qrCode,
matchingCurrencies = matchingCurrencies,
)
}
return ClassifiedQrContent.Unknown(qrCode)
}
private fun tryParsePaymentUri(qrCode: String, coins: List<CryptoCurrency.Coin>): ClassifiedQrContent.PaymentUri? {
return coins.firstNotNullOfOrNull { coin ->
val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network)
.sortedByDescending { it.length }
.firstOrNull { qrCode.startsWith(it) }
?: return@firstNotNullOfOrNull null
val withoutScheme = qrCode.removePrefix(matchedScheme)
val address = withoutScheme.takeWhile {
it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER
}
val params = extractParameters(withoutScheme)
if (address.isBlank()) return@firstNotNullOfOrNull null
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
}
ClassifiedQrContent.PaymentUri(
currency = coin,
address = address,
amount = amount,
memo = memo,
)
}
}
private fun isDAppWcUrl(qrCode: String): Boolean {
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
val uriParam = extractParameters(qrCode)[PARAM_URI] ?: return false
val decodedUri = runCatching { URLDecoder.decode(uriParam, CHARSET_UTF8) }.getOrDefault(uriParam)
return decodedUri.startsWith(WC_PREFIX)
}
private 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()
}
internal interface BlockchainDataProvider {
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean
}
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)
}
}
private companion object {
const val HTTP_PREFIX = "http://"
const val HTTPS_PREFIX = "https://"
const val PARAM_URI = "uri"
const val WC_PREFIX = "wc:"
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 CHARSET_UTF8 = "UTF-8"
}
}

View file

@ -0,0 +1,303 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class QrContentClassifierTest {
private val blockchainDataProvider = mockk<QrContentClassifier.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
}
private val classifier = QrContentClassifier(blockchainDataProvider)
// region WalletConnect
@Test
fun `WalletConnect URI is classified correctly`() {
val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2"
val result = classifier.classify(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.classify(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.classify(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.classify(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::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.classify(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region PaymentUri
@Test
fun `Bitcoin BIP-021 URI with amount is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5"
val result = classifier.classify(qr, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.currency).isEqualTo(bitcoinCoin)
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5"))
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI without params returns address only`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isNull()
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI with message param is parsed as memo`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0"))
assertThat(paymentUri.memo).isEqualTo("test memo")
}
@Test
fun `URI with memo parameter is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.memo).isEqualTo("hello")
}
@Test
fun `Ethereum ERC-681 URI with chain_id and function is parsed`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5"
val result = classifier.classify(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5"))
}
@Test
fun `URI scheme not matching user currencies falls through`() {
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Longest matching scheme is preferred`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns
listOf("bitcoin:", "bitcoin://")
val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
}
// endregion
// region PlainAddress
@Test
fun `Plain address matches single currency`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.classify(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.classify(address, listOf(ethereumCoin, bscCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(2)
}
// endregion
// region Unknown
@Test
fun `Random string returns Unknown`() {
val result = classifier.classify("hello world", listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world")
}
@Test
fun `Empty string returns Unknown`() {
val result = classifier.classify("", listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Empty currencies list returns Unknown`() {
val result = classifier.classify("0x1234567890abcdef1234567890abcdef12345678", emptyList())
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region Edge cases
@Test
fun `Tokens are filtered out, only Coins are used`() {
val token = CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
),
network = buildNetwork("ethereum"),
name = "USDT",
symbol = "USDT",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
)
val result = classifier.classify("0x1234", listOf(token))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Duplicate coins with same network are deduplicated`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.classify(address, listOf(ethereumCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(1)
}
@Test
fun `Payment URI takes priority over plain address match`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1"
val result = classifier.classify(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::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 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
}