Updated on 2026-08-14
This commit is contained in:
parent
8a4dbe1b4c
commit
8a79e65e2e
18 changed files with 1099 additions and 101 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue