Updated on 2026-08-14
This commit is contained in:
parent
263ee55122
commit
0f600e9dcf
12 changed files with 489 additions and 11 deletions
|
|
@ -3,6 +3,7 @@ package com.tangem.tap.di.domain
|
||||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||||
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
|
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
|
||||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||||
|
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -24,4 +25,10 @@ internal object QrScanningDomainModule {
|
||||||
fun provideEmitQrScannedEventUseCase(repository: QrScanningEventsRepository): EmitQrScannedEventUseCase {
|
fun provideEmitQrScannedEventUseCase(repository: QrScanningEventsRepository): EmitQrScannedEventUseCase {
|
||||||
return EmitQrScannedEventUseCase(repository)
|
return EmitQrScannedEventUseCase(repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideParseQrCodeUseCase(repository: QrScanningEventsRepository): ParseQrCodeUseCase {
|
||||||
|
return ParseQrCodeUseCase(repository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,10 +25,14 @@ import com.tangem.core.analytics.Analytics
|
||||||
import com.tangem.core.navigation.AppScreen
|
import com.tangem.core.navigation.AppScreen
|
||||||
import com.tangem.core.navigation.NavigationAction
|
import com.tangem.core.navigation.NavigationAction
|
||||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||||
|
import com.tangem.domain.qrscanning.models.QrResult
|
||||||
import com.tangem.domain.qrscanning.models.SourceType
|
import com.tangem.domain.qrscanning.models.SourceType
|
||||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||||
|
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
|
||||||
import com.tangem.domain.tokens.legacy.TradeCryptoAction
|
import com.tangem.domain.tokens.legacy.TradeCryptoAction
|
||||||
|
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||||
import com.tangem.feature.qrscanning.QrScanningRouter
|
import com.tangem.feature.qrscanning.QrScanningRouter
|
||||||
|
import com.tangem.features.send.api.navigation.SendRouter.Companion.CRYPTO_CURRENCY_KEY
|
||||||
import com.tangem.sdk.extensions.hideSoftKeyboard
|
import com.tangem.sdk.extensions.hideSoftKeyboard
|
||||||
import com.tangem.tap.common.KeyboardObserver
|
import com.tangem.tap.common.KeyboardObserver
|
||||||
import com.tangem.tap.common.analytics.events.Token
|
import com.tangem.tap.common.analytics.events.Token
|
||||||
|
|
@ -60,6 +64,7 @@ import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import java.text.DecimalFormatSymbols
|
import java.text.DecimalFormatSymbols
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -82,11 +87,17 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
|
||||||
private val sendSubscriber = SendStateSubscriber(this)
|
private val sendSubscriber = SendStateSubscriber(this)
|
||||||
private lateinit var keyboardObserver: KeyboardObserver
|
private lateinit var keyboardObserver: KeyboardObserver
|
||||||
|
|
||||||
|
private val cryptoCurrency: CryptoCurrency?
|
||||||
|
get() = arguments?.getParcelable(CRYPTO_CURRENCY_KEY)
|
||||||
|
|
||||||
val binding: FragmentSendBinding by viewBinding(FragmentSendBinding::bind)
|
val binding: FragmentSendBinding by viewBinding(FragmentSendBinding::bind)
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var listenToQrScanningUseCase: ListenToQrScanningUseCase
|
lateinit var listenToQrScanningUseCase: ListenToQrScanningUseCase
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var parseQrCodeUseCase: ParseQrCodeUseCase
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
lifecycle.addObserver(viewModel)
|
lifecycle.addObserver(viewModel)
|
||||||
|
|
@ -177,13 +188,21 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
|
||||||
listenToQrScanningUseCase(SourceType.SEND)
|
listenToQrScanningUseCase(SourceType.SEND)
|
||||||
.getOrElse { emptyFlow() }
|
.getOrElse { emptyFlow() }
|
||||||
.flowWithLifecycle(this@SendFragment.lifecycle, minActiveState = Lifecycle.State.CREATED)
|
.flowWithLifecycle(this@SendFragment.lifecycle, minActiveState = Lifecycle.State.CREATED)
|
||||||
.collect {
|
.collect { rawQr ->
|
||||||
delay(200)
|
delay(200)
|
||||||
|
|
||||||
// Delayed launch is needed in order for the UI to be drawn and to process the sent events.
|
// Delayed launch is needed in order for the UI to be drawn and to process the sent events.
|
||||||
// If do not use the delay, then etAmount error field is not displayed when
|
// If do not use the delay, then etAmount error field is not displayed when
|
||||||
// inserting an incorrect amount by shareUri
|
// inserting an incorrect amount by shareUri
|
||||||
onCodeScanned(it)
|
cryptoCurrency?.let { cryptoCurrency ->
|
||||||
|
parseQrCodeUseCase(rawQr, cryptoCurrency = cryptoCurrency).fold(
|
||||||
|
ifLeft = {
|
||||||
|
onCodeScanned(QrResult(address = rawQr))
|
||||||
|
Timber.w(it)
|
||||||
|
},
|
||||||
|
ifRight = { onCodeScanned(it) },
|
||||||
|
)
|
||||||
|
} ?: onCodeScanned(QrResult(address = rawQr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -254,15 +273,18 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
|
||||||
.launchIn(mainScope)
|
.launchIn(mainScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCodeScanned(scannedCode: String) {
|
private fun onCodeScanned(parsedQr: QrResult) {
|
||||||
if (scannedCode.isEmpty()) return
|
if (parsedQr.address.isEmpty()) return
|
||||||
|
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
PasteAddress(
|
PasteAddress(
|
||||||
data = scannedCode,
|
data = parsedQr.address,
|
||||||
sourceType = Token.Send.AddressEntered.SourceType.QRCode,
|
sourceType = Token.Send.AddressEntered.SourceType.QRCode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parsedQr.amount?.let { amount ->
|
||||||
|
store.dispatchOnMain(AmountAction.SetAmount(amount, isUserInput = false))
|
||||||
|
}
|
||||||
store.dispatch(TruncateOrRestore(!binding.lSendAddress.etAddress.isFocused))
|
store.dispatch(TruncateOrRestore(!binding.lSendAddress.etAddress.isFocused))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,20 @@ dependencies {
|
||||||
/** Domain */
|
/** Domain */
|
||||||
implementation(projects.domain.qrScanning)
|
implementation(projects.domain.qrScanning)
|
||||||
implementation(projects.domain.qrScanning.models)
|
implementation(projects.domain.qrScanning.models)
|
||||||
|
implementation(projects.domain.tokens.models)
|
||||||
|
|
||||||
|
implementation(projects.core.ui)
|
||||||
|
|
||||||
|
/** SdK */
|
||||||
|
implementation(deps.tangem.blockchain)
|
||||||
|
|
||||||
/** DI */
|
/** DI */
|
||||||
implementation(deps.hilt.android)
|
implementation(deps.hilt.android)
|
||||||
kapt(deps.hilt.kapt)
|
kapt(deps.hilt.kapt)
|
||||||
|
|
||||||
|
/** Tests */
|
||||||
|
testImplementation(deps.test.junit)
|
||||||
|
testImplementation(deps.test.coroutine)
|
||||||
|
testImplementation(deps.test.mockk)
|
||||||
|
testImplementation(deps.test.truth)
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
package com.tangem.data.qrscanning.repository
|
package com.tangem.data.qrscanning.repository
|
||||||
|
|
||||||
|
import com.tangem.blockchain.common.Blockchain
|
||||||
|
import com.tangem.domain.qrscanning.models.QrResult
|
||||||
import com.tangem.domain.qrscanning.models.SourceType
|
import com.tangem.domain.qrscanning.models.SourceType
|
||||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||||
|
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
||||||
|
|
||||||
|
|
@ -19,4 +24,122 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
||||||
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
|
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
|
||||||
.filter { it.type == type }
|
.filter { it.type == type }
|
||||||
.map { it.qrCode }
|
.map { it.qrCode }
|
||||||
|
|
||||||
|
override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult {
|
||||||
|
val withoutSchema = stripSchema(qrCode, cryptoCurrency)
|
||||||
|
|
||||||
|
// A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care
|
||||||
|
// about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`.
|
||||||
|
//
|
||||||
|
// We're extracting the destination address by parsing the given string until we meet
|
||||||
|
// any of the possible string delimiters (@ ? /).
|
||||||
|
val address = withoutSchema.takeWhile { char ->
|
||||||
|
char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = QrResult(address = address)
|
||||||
|
|
||||||
|
extractParameters(withoutSchema)
|
||||||
|
.forEach {
|
||||||
|
when (it.key) {
|
||||||
|
Parameter.Amount -> {
|
||||||
|
// According to BIP-0021, the value is specified in decimals. No conversion needed
|
||||||
|
result.amount = it.value.toBigDecimalOrNull()
|
||||||
|
}
|
||||||
|
Parameter.Message,
|
||||||
|
Parameter.Memo,
|
||||||
|
-> {
|
||||||
|
result.memo = URLDecoder.decode(it.value, "UTF-8")
|
||||||
|
}
|
||||||
|
Parameter.Address -> {
|
||||||
|
// Overrides destination address for token transfers (ERC-681)
|
||||||
|
if (cryptoCurrency is CryptoCurrency.Token) {
|
||||||
|
// `address` parameter is used only if the contract address, encoded in the QR,
|
||||||
|
// matches the contract address of the token.
|
||||||
|
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routine
|
||||||
|
if (cryptoCurrency.contractAddress.equals(address, ignoreCase = true)) {
|
||||||
|
result.address = it.value
|
||||||
|
} else {
|
||||||
|
return QrResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Parameter.Value,
|
||||||
|
Parameter.Uint256,
|
||||||
|
-> {
|
||||||
|
// Extra convert parses scientific notation to decimal
|
||||||
|
// This is necessary to be able comparing BigDecimal values
|
||||||
|
result.amount = it.value.toBigDecimalOrNull()
|
||||||
|
?.toPlainString()?.toBigDecimalOrNull()
|
||||||
|
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripSchema(raw: String, currency: CryptoCurrency): String {
|
||||||
|
val qrSchemas = Blockchain.fromId(currency.network.id.value).getShareScheme()
|
||||||
|
|
||||||
|
// The most specific (i.e. the most lengthy) prefixes always come first
|
||||||
|
qrSchemas
|
||||||
|
.sortedByDescending { it.length }
|
||||||
|
.forEach { schema ->
|
||||||
|
val stripped = raw.split(schema)
|
||||||
|
|
||||||
|
if (stripped.size > 1) return stripped.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractParameters(from: String): Map<Parameter, String> {
|
||||||
|
val parametersBlock = from.substringAfter(PARAM_DELIMITER)
|
||||||
|
if (parametersBlock.isBlank()) return emptyMap()
|
||||||
|
|
||||||
|
val paramList = parametersBlock.split(PARAMS_DELIMITER)
|
||||||
|
.mapNotNull { param ->
|
||||||
|
val parameterWithValue = param.split(PARAM_VALUE_DELIMITER)
|
||||||
|
if (parameterWithValue.size == 2) {
|
||||||
|
val name = Parameter.getParam(parameterWithValue.first())
|
||||||
|
val value = parameterWithValue.last()
|
||||||
|
|
||||||
|
if (name != null) {
|
||||||
|
name to value
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.associate { it }
|
||||||
|
|
||||||
|
return paramList
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Parameter {
|
||||||
|
Amount,
|
||||||
|
Message,
|
||||||
|
Memo,
|
||||||
|
Address,
|
||||||
|
Value,
|
||||||
|
Uint256,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getParam(name: String): Parameter? {
|
||||||
|
return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// See https://eips.ethereum.org/EIPS/eip-681 for details.
|
||||||
|
const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ]
|
||||||
|
const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ]
|
||||||
|
const val PARAM_DELIMITER = '?' // BIP-021, ERC-681
|
||||||
|
const val PARAMS_DELIMITER = '&'
|
||||||
|
const val PARAM_VALUE_DELIMITER = '='
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
package com.tangem.data.qrscanning
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import com.tangem.blockchain.common.Blockchain
|
||||||
|
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
||||||
|
import com.tangem.domain.qrscanning.models.QrResult
|
||||||
|
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||||
|
import com.tangem.domain.tokens.model.Network
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.Test
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
internal class DefaultQrScanningEventsRepositoryTest {
|
||||||
|
|
||||||
|
private val repository = DefaultQrScanningEventsRepository()
|
||||||
|
|
||||||
|
private val cryptoCurrencyId = mockk<CryptoCurrency.ID>()
|
||||||
|
private val network = mockk<Network>()
|
||||||
|
private val cryptoCurrency = CryptoCurrency.Coin(
|
||||||
|
id = cryptoCurrencyId,
|
||||||
|
network = network,
|
||||||
|
name = "blockchain",
|
||||||
|
symbol = "symbol",
|
||||||
|
decimals = 18,
|
||||||
|
iconUrl = null,
|
||||||
|
isCustom = false,
|
||||||
|
)
|
||||||
|
private val tokenCryptoCurrency = CryptoCurrency.Token(
|
||||||
|
id = cryptoCurrencyId,
|
||||||
|
network = network,
|
||||||
|
name = "blockchain",
|
||||||
|
symbol = "symbol",
|
||||||
|
decimals = 7,
|
||||||
|
iconUrl = null,
|
||||||
|
isCustom = false,
|
||||||
|
contractAddress = "0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val garbage = "some_garbage="
|
||||||
|
|
||||||
|
private val schema1 = "bitcoin"
|
||||||
|
private val schema2 = "ethereum"
|
||||||
|
|
||||||
|
private val address1 = "bc1pw83rs5s75na2g7ec8yqgekr3ae209ye7ck2ftakjnh8tv3xzw8ls6wgt62"
|
||||||
|
private val address2 = "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
|
||||||
|
private val address3 = "pay-0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
|
||||||
|
private val address4 = "0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7"
|
||||||
|
|
||||||
|
private val function = "/transfer"
|
||||||
|
|
||||||
|
private val someParam = "someParam"
|
||||||
|
private val someParamValue = "someParamValue"
|
||||||
|
|
||||||
|
private val addressParam = "address"
|
||||||
|
private val addressParamValue = "0xc00f86ab93cd0bd3a60213583d0fe35aaa1ace23"
|
||||||
|
|
||||||
|
private val amountParam = "amount"
|
||||||
|
private val someAmountParamValue = "amount"
|
||||||
|
private val amountParamValue = "123.123"
|
||||||
|
|
||||||
|
private val valueParam = "value"
|
||||||
|
private val someValueParamValue = "amount"
|
||||||
|
private val valueParamValue2 = "1.88e10"
|
||||||
|
private val valueParamValue3 = "1.68E11"
|
||||||
|
private val valueParamValue4 = "23000000000"
|
||||||
|
|
||||||
|
private val memoParam = "memo"
|
||||||
|
private val memoParamValue = "a%20random%20memo"
|
||||||
|
private val memoParamValueUtf8 = "a random memo"
|
||||||
|
|
||||||
|
private val messageParam = "message"
|
||||||
|
private val messageParamValue = "some%20message"
|
||||||
|
private val messageParamValueUft8 = "some message"
|
||||||
|
private val messageParamValue2 = "message"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBip021() {
|
||||||
|
every { network.id } returns Network.ID(Blockchain.Bitcoin.id)
|
||||||
|
positiveCase(
|
||||||
|
"$schema1:$address1",
|
||||||
|
QrResult(address = address1),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema1:$address1",
|
||||||
|
QrResult(address = address1),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address1?$someParam=$someParamValue",
|
||||||
|
QrResult(address = address1),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address1?$someParam=$someParamValue&$amountParam=$someAmountParamValue",
|
||||||
|
QrResult(address = address1),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue",
|
||||||
|
QrResult(address = address1, amount = BigDecimal(amountParamValue)),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue&$memoParam=$memoParamValue",
|
||||||
|
QrResult(address = address1, amount = BigDecimal(amountParamValue), memo = memoParamValueUtf8),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address1?$someParam=$someParamValue&$messageParam=$messageParamValue",
|
||||||
|
QrResult(address = address1, memo = messageParamValueUft8),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address1?$someParam=$someParamValue&$messageParam=$messageParamValue2",
|
||||||
|
QrResult(address = address1, memo = messageParamValue2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
negativeCase(
|
||||||
|
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue",
|
||||||
|
QrResult(address = address1, memo = messageParamValue2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testErc681Coin() {
|
||||||
|
every { network.id } returns Network.ID(Blockchain.Ethereum.id)
|
||||||
|
positiveCase(
|
||||||
|
address2,
|
||||||
|
QrResult(address = address2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address2?$someParam=$someParamValue",
|
||||||
|
QrResult(address = address2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$schema2:$address2",
|
||||||
|
QrResult(address = address2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$schema2:$address3",
|
||||||
|
QrResult(address = address2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema2:$address2",
|
||||||
|
QrResult(address = address2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
|
||||||
|
QrResult(address = address2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$someAmountParamValue",
|
||||||
|
QrResult(address = address2),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue2",
|
||||||
|
QrResult(address = address2, amount = BigDecimal("0.0000000188")),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue3",
|
||||||
|
QrResult(address = address2, amount = BigDecimal("0.000000168")),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue4",
|
||||||
|
QrResult(address = address2, amount = BigDecimal("0.000000023")),
|
||||||
|
cryptoCurrency,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testErc681Token() {
|
||||||
|
every { network.id } returns Network.ID(Blockchain.Ethereum.id)
|
||||||
|
positiveCase(
|
||||||
|
address2,
|
||||||
|
QrResult(address = address2),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address2?$someParam=$someParamValue",
|
||||||
|
QrResult(address = address2),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$schema2:$address2",
|
||||||
|
QrResult(address = address2),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$schema2:$address3",
|
||||||
|
QrResult(address = address2),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$garbage$schema2:$address2",
|
||||||
|
QrResult(address = address2),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$schema2:$address4$function?$addressParam=$addressParamValue",
|
||||||
|
QrResult(address = addressParamValue),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address2?$someParam=$someParamValue&$valueParam=$someValueParamValue",
|
||||||
|
QrResult(address = address2),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue2",
|
||||||
|
QrResult(address = address2, amount = BigDecimal("1880")),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue3",
|
||||||
|
QrResult(address = address2, amount = BigDecimal("16800")),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
positiveCase(
|
||||||
|
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue4",
|
||||||
|
QrResult(address = address2, amount = BigDecimal("2300")),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
negativeCase(
|
||||||
|
"$address2?$someParam=$someParamValue&$amountParam=$amountParamValue",
|
||||||
|
QrResult(address = address2, amount = BigDecimal("123.123"), memo = memoParamValueUtf8),
|
||||||
|
tokenCryptoCurrency,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positiveCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {
|
||||||
|
val actual = repository.parseQrCode(input, cryptoCurrency)
|
||||||
|
Truth.assertThat(actual.address).isEqualTo(expected.address)
|
||||||
|
Truth.assertThat(actual.amount).isEqualTo(expected.amount)
|
||||||
|
Truth.assertThat(actual.memo).isEqualTo(expected.memo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun negativeCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {
|
||||||
|
val actual = repository.parseQrCode(input, cryptoCurrency)
|
||||||
|
Truth.assertThat(actual).isNotEqualTo(expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,6 +93,10 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? {
|
||||||
"areon-network/test" -> Blockchain.AreonTestnet
|
"areon-network/test" -> Blockchain.AreonTestnet
|
||||||
"pulsechain" -> Blockchain.PulseChain
|
"pulsechain" -> Blockchain.PulseChain
|
||||||
"pulsechain/test" -> Blockchain.PulseChainTestnet
|
"pulsechain/test" -> Blockchain.PulseChainTestnet
|
||||||
|
"nexa" -> Blockchain.Nexa // FIXME
|
||||||
|
"nexa/testnet" -> Blockchain.NexaTestnet // FIXME
|
||||||
|
"zksync" -> Blockchain.ZkSyncEra // FIXME
|
||||||
|
"zksync/testnet" -> Blockchain.ZkSyncEraTestnet // FIXME
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +191,10 @@ fun Blockchain.toNetworkId(): String {
|
||||||
Blockchain.AreonTestnet -> "areon-network/test"
|
Blockchain.AreonTestnet -> "areon-network/test"
|
||||||
Blockchain.PulseChain -> "pulsechain"
|
Blockchain.PulseChain -> "pulsechain"
|
||||||
Blockchain.PulseChainTestnet -> "pulsechain/test"
|
Blockchain.PulseChainTestnet -> "pulsechain/test"
|
||||||
|
Blockchain.ZkSyncEra -> "zksync" // FIXME
|
||||||
|
Blockchain.ZkSyncEraTestnet -> "zksync/testnet" // FIXME
|
||||||
|
Blockchain.Nexa -> "nexa" // FIXME
|
||||||
|
Blockchain.NexaTestnet -> "nexa/testnet" // FIXME
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,6 +257,10 @@ fun Blockchain.toCoinId(): String {
|
||||||
Blockchain.Aurora, Blockchain.AuroraTestnet -> "aurora-near"
|
Blockchain.Aurora, Blockchain.AuroraTestnet -> "aurora-near"
|
||||||
Blockchain.Areon, Blockchain.AreonTestnet -> "areon-network"
|
Blockchain.Areon, Blockchain.AreonTestnet -> "areon-network"
|
||||||
Blockchain.PulseChain, Blockchain.PulseChainTestnet -> "pulsechain"
|
Blockchain.PulseChain, Blockchain.PulseChainTestnet -> "pulsechain"
|
||||||
|
Blockchain.ZkSyncEra -> "zksync" // FIXME
|
||||||
|
Blockchain.ZkSyncEraTestnet -> "zksync/testnet" // FIXME
|
||||||
|
Blockchain.Nexa -> "nexa" // FIXME
|
||||||
|
Blockchain.NexaTestnet -> "nexa/testnet" // FIXME
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,4 +288,8 @@ private const val NODL_AMOUNT_TO_CREATE_ACCOUNT = 1.5
|
||||||
private val excludedBlockchains = listOf(
|
private val excludedBlockchains = listOf(
|
||||||
Blockchain.Unknown,
|
Blockchain.Unknown,
|
||||||
Blockchain.Playa3ull,
|
Blockchain.Playa3ull,
|
||||||
|
Blockchain.ZkSyncEra,
|
||||||
|
Blockchain.ZkSyncEraTestnet,
|
||||||
|
Blockchain.Nexa,
|
||||||
|
Blockchain.NexaTestnet,
|
||||||
)
|
)
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(deps.plugins.kotlin.jvm)
|
alias(deps.plugins.android.library)
|
||||||
|
alias(deps.plugins.kotlin.android)
|
||||||
id("configuration")
|
id("configuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.tangem.domain.qrscanning"
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
/** Domain */
|
/** Domain */
|
||||||
implementation(projects.domain.qrScanning.models)
|
implementation(projects.domain.qrScanning.models)
|
||||||
|
implementation(projects.domain.tokens.models)
|
||||||
|
|
||||||
implementation(deps.kotlin.coroutines)
|
implementation(deps.kotlin.coroutines)
|
||||||
implementation(deps.arrow.core)
|
implementation(deps.arrow.core)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.tangem.domain.qrscanning.models
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
data class QrResult(
|
||||||
|
var address: String = "",
|
||||||
|
var amount: BigDecimal? = null,
|
||||||
|
var memo: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.tangem.domain.qrscanning.repository
|
package com.tangem.domain.qrscanning.repository
|
||||||
|
|
||||||
|
import com.tangem.domain.qrscanning.models.QrResult
|
||||||
import com.tangem.domain.qrscanning.models.SourceType
|
import com.tangem.domain.qrscanning.models.SourceType
|
||||||
|
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface QrScanningEventsRepository {
|
interface QrScanningEventsRepository {
|
||||||
|
|
@ -8,4 +10,6 @@ interface QrScanningEventsRepository {
|
||||||
suspend fun emitResult(type: SourceType, qrCode: String)
|
suspend fun emitResult(type: SourceType, qrCode: String)
|
||||||
|
|
||||||
fun subscribeToScanningResults(type: SourceType): Flow<String>
|
fun subscribeToScanningResults(type: SourceType): Flow<String>
|
||||||
|
|
||||||
|
fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.tangem.domain.qrscanning.usecases
|
||||||
|
|
||||||
|
import arrow.core.Either
|
||||||
|
import arrow.core.left
|
||||||
|
import arrow.core.right
|
||||||
|
import com.tangem.domain.qrscanning.models.QrResult
|
||||||
|
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||||
|
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||||
|
|
||||||
|
class ParseQrCodeUseCase(
|
||||||
|
val repository: QrScanningEventsRepository,
|
||||||
|
) {
|
||||||
|
operator fun invoke(qrCode: String, cryptoCurrency: CryptoCurrency): Either<Exception, QrResult> {
|
||||||
|
return try {
|
||||||
|
repository.parseQrCode(qrCode, cryptoCurrency).right()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.left()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||||
import com.tangem.domain.common.util.cardTypesResolver
|
import com.tangem.domain.common.util.cardTypesResolver
|
||||||
|
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
|
||||||
import com.tangem.domain.redux.LegacyAction
|
import com.tangem.domain.redux.LegacyAction
|
||||||
import com.tangem.domain.redux.ReduxStateHolder
|
import com.tangem.domain.redux.ReduxStateHolder
|
||||||
import com.tangem.domain.tokens.*
|
import com.tangem.domain.tokens.*
|
||||||
|
|
@ -32,7 +33,10 @@ import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
|
||||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
import com.tangem.domain.wallets.models.UserWallet
|
import com.tangem.domain.wallets.models.UserWallet
|
||||||
import com.tangem.domain.wallets.models.UserWalletId
|
import com.tangem.domain.wallets.models.UserWalletId
|
||||||
import com.tangem.domain.wallets.usecase.*
|
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||||
|
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||||
|
import com.tangem.domain.wallets.usecase.ValidateWalletAddressUseCase
|
||||||
|
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
|
||||||
import com.tangem.features.send.api.navigation.SendRouter
|
import com.tangem.features.send.api.navigation.SendRouter
|
||||||
import com.tangem.features.send.impl.navigation.InnerSendRouter
|
import com.tangem.features.send.impl.navigation.InnerSendRouter
|
||||||
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
|
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
|
||||||
|
|
@ -75,12 +79,12 @@ internal class SendViewModel @Inject constructor(
|
||||||
private val sendTransactionUseCase: SendTransactionUseCase,
|
private val sendTransactionUseCase: SendTransactionUseCase,
|
||||||
private val createTransactionUseCase: CreateTransactionUseCase,
|
private val createTransactionUseCase: CreateTransactionUseCase,
|
||||||
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
|
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
|
||||||
private val parseSharedAddressUseCase: ParseSharedAddressUseCase,
|
|
||||||
private val walletManagersFacade: WalletManagersFacade,
|
private val walletManagersFacade: WalletManagersFacade,
|
||||||
private val reduxStateHolder: ReduxStateHolder,
|
private val reduxStateHolder: ReduxStateHolder,
|
||||||
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
|
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
|
||||||
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
|
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
|
||||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||||
|
private val parseQrCodeUseCase: ParseQrCodeUseCase,
|
||||||
currencyChecksRepository: CurrencyChecksRepository,
|
currencyChecksRepository: CurrencyChecksRepository,
|
||||||
isFeeApproximateUseCase: IsFeeApproximateUseCase,
|
isFeeApproximateUseCase: IsFeeApproximateUseCase,
|
||||||
getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
|
getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
|
||||||
|
|
@ -178,6 +182,7 @@ internal class SendViewModel @Inject constructor(
|
||||||
private var recipientsJobHolder = JobHolder()
|
private var recipientsJobHolder = JobHolder()
|
||||||
private var feeJobHolder = JobHolder()
|
private var feeJobHolder = JobHolder()
|
||||||
private var addressValidationJobHolder = JobHolder()
|
private var addressValidationJobHolder = JobHolder()
|
||||||
|
private var memoValidationJobHolder = JobHolder()
|
||||||
private var sendNotificationsJobHolder = JobHolder()
|
private var sendNotificationsJobHolder = JobHolder()
|
||||||
private var feeNotificationsJobHolder = JobHolder()
|
private var feeNotificationsJobHolder = JobHolder()
|
||||||
private var qrScannerJobHolder = JobHolder()
|
private var qrScannerJobHolder = JobHolder()
|
||||||
|
|
@ -509,13 +514,14 @@ internal class SendViewModel @Inject constructor(
|
||||||
// region recipient state clicks
|
// region recipient state clicks
|
||||||
fun onRecipientAddressScanned(address: String) {
|
fun onRecipientAddressScanned(address: String) {
|
||||||
viewModelScope.launch(dispatchers.main) {
|
viewModelScope.launch(dispatchers.main) {
|
||||||
parseSharedAddressUseCase(address, cryptoCurrency.network).fold(
|
parseQrCodeUseCase(address, cryptoCurrency).fold(
|
||||||
ifRight = { parsedCode ->
|
ifRight = { parsedCode ->
|
||||||
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
|
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
|
||||||
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
|
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
|
||||||
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
|
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
|
||||||
},
|
},
|
||||||
ifLeft = {
|
ifLeft = {
|
||||||
|
onRecipientAddressValueChange(address, EnterAddressSource.QRCode)
|
||||||
Timber.w(it)
|
Timber.w(it)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -543,7 +549,7 @@ internal class SendViewModel @Inject constructor(
|
||||||
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
|
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
|
||||||
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
|
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
|
||||||
}
|
}
|
||||||
}.saveIn(addressValidationJobHolder)
|
}.saveIn(memoValidationJobHolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun validateAddress(value: String): Boolean {
|
private suspend fun validateAddress(value: String): Boolean {
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ leakcanary = "2.13"
|
||||||
# endregion Other libraries
|
# endregion Other libraries
|
||||||
|
|
||||||
# region Tangem
|
# region Tangem
|
||||||
tangemBlockchainSdk = "develop-514"
|
tangemBlockchainSdk = "develop-528"
|
||||||
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
||||||
tangemCardSdk = "develop-332"
|
tangemCardSdk = "develop-332"
|
||||||
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue