Updated on 2026-08-14

This commit is contained in:
Tangem 2024-03-15 20:41:55 +05:00
commit 1f183f616a
36 changed files with 627 additions and 94 deletions

View file

@ -65,6 +65,8 @@ dependencies {
implementation(projects.domain.visa)
implementation(projects.domain.onboarding)
implementation(projects.domain.feedback)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.common)
implementation(projects.core.analytics)
@ -95,6 +97,7 @@ dependencies {
implementation(projects.data.promo)
implementation(projects.data.onboarding)
implementation(projects.data.feedback)
implementation(projects.data.qrScanning)
/** Features */
implementation(projects.features.onboarding)
@ -112,9 +115,9 @@ dependencies {
implementation(projects.features.wallet.impl)
implementation(projects.features.tokendetails.api)
implementation(projects.features.tokendetails.impl)
implementation(projects.features.send.api)
implementation(projects.features.manageTokens.api)
implementation(projects.features.manageTokens.impl)
implementation(projects.features.send.api)
implementation(projects.features.send.impl)
implementation(projects.features.qrScanning.api)
implementation(projects.features.qrScanning.impl)

View file

@ -1,7 +1,9 @@
package com.tangem.tap.di.domain
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -17,4 +19,16 @@ internal object QrScanningDomainModule {
fun provideListenToQrScanUseCase(repository: QrScanningEventsRepository): ListenToQrScanningUseCase {
return ListenToQrScanningUseCase(repository)
}
@Provides
@Singleton
fun provideEmitQrScannedEventUseCase(repository: QrScanningEventsRepository): EmitQrScannedEventUseCase {
return EmitQrScannedEventUseCase(repository)
}
@Provides
@Singleton
fun provideParseQrCodeUseCase(repository: QrScanningEventsRepository): ParseQrCodeUseCase {
return ParseQrCodeUseCase(repository)
}
}

View file

@ -13,12 +13,12 @@ import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletconnect.WalletConnectActions
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.SourceType
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState

View file

@ -2,8 +2,8 @@ package com.tangem.tap.features.details.ui.walletconnect
import androidx.lifecycle.*
import arrow.core.getOrElse
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectSession
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState

View file

@ -7,7 +7,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.SourceType
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.store

View file

@ -25,10 +25,14 @@ import com.tangem.core.analytics.Analytics
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
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.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
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.SourceType
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import com.tangem.features.send.api.navigation.SendRouter.Companion.CRYPTO_CURRENCY_KEY
import com.tangem.sdk.extensions.hideSoftKeyboard
import com.tangem.tap.common.KeyboardObserver
import com.tangem.tap.common.analytics.events.Token
@ -60,6 +64,7 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.text.DecimalFormatSymbols
import javax.inject.Inject
@ -82,11 +87,17 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
private val sendSubscriber = SendStateSubscriber(this)
private lateinit var keyboardObserver: KeyboardObserver
private val cryptoCurrency: CryptoCurrency?
get() = arguments?.getParcelable(CRYPTO_CURRENCY_KEY)
val binding: FragmentSendBinding by viewBinding(FragmentSendBinding::bind)
@Inject
lateinit var listenToQrScanningUseCase: ListenToQrScanningUseCase
@Inject
lateinit var parseQrCodeUseCase: ParseQrCodeUseCase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
@ -177,13 +188,21 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
listenToQrScanningUseCase(SourceType.SEND)
.getOrElse { emptyFlow() }
.flowWithLifecycle(this@SendFragment.lifecycle, minActiveState = Lifecycle.State.CREATED)
.collect {
.collect { rawQr ->
delay(200)
// 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
// 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)
}
private fun onCodeScanned(scannedCode: String) {
if (scannedCode.isEmpty()) return
private fun onCodeScanned(parsedQr: QrResult) {
if (parsedQr.address.isEmpty()) return
store.dispatch(
PasteAddress(
data = scannedCode,
data = parsedQr.address,
sourceType = Token.Send.AddressEntered.SourceType.QRCode,
),
)
parsedQr.amount?.let { amount ->
store.dispatchOnMain(AmountAction.SetAmount(amount, isUserInput = false))
}
store.dispatch(TruncateOrRestore(!binding.lSendAddress.etAddress.isFocused))
}

1
data/qr-scanning/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,34 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.qrscanning"
}
dependencies {
/** Domain */
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models)
implementation(projects.core.ui)
/** SdK */
implementation(deps.tangem.blockchain)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.qrscanning.di
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object QrScanningDataModule {
@Provides
@Singleton
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
return DefaultQrScanningEventsRepository()
}
}

View file

@ -0,0 +1,145 @@
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.repository.QrScanningEventsRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
import java.net.URLDecoder
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
private data class QrScanningEvent(val type: SourceType, val qrCode: String)
private val scannedEvents = MutableSharedFlow<QrScanningEvent>()
override suspend fun emitResult(type: SourceType, qrCode: String) {
scannedEvents.emit(QrScanningEvent(type, qrCode))
}
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
.filter { it.type == type }
.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 = '='
}
}

View file

@ -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)
}
}

View file

@ -93,6 +93,10 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? {
"areon-network/test" -> Blockchain.AreonTestnet
"pulsechain" -> Blockchain.PulseChain
"pulsechain/test" -> Blockchain.PulseChainTestnet
"nexa" -> Blockchain.Nexa // FIXME
"nexa/testnet" -> Blockchain.NexaTestnet // FIXME
"zksync" -> Blockchain.ZkSyncEra // FIXME
"zksync/testnet" -> Blockchain.ZkSyncEraTestnet // FIXME
else -> null
}
}
@ -187,6 +191,10 @@ fun Blockchain.toNetworkId(): String {
Blockchain.AreonTestnet -> "areon-network/test"
Blockchain.PulseChain -> "pulsechain"
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.Areon, Blockchain.AreonTestnet -> "areon-network"
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(
Blockchain.Unknown,
Blockchain.Playa3ull,
Blockchain.ZkSyncEra,
Blockchain.ZkSyncEraTestnet,
Blockchain.Nexa,
Blockchain.NexaTestnet,
)

1
domain/qr-scanning/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,19 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.qrscanning"
}
dependencies {
/** Domain */
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
}

1
domain/qr-scanning/models/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,4 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}

View file

@ -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,
)

View file

@ -1,4 +1,4 @@
package com.tangem.feature.qrscanning
package com.tangem.domain.qrscanning.models
enum class SourceType {
WALLET_CONNECT,

View file

@ -0,0 +1,15 @@
package com.tangem.domain.qrscanning.repository
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.tokens.model.CryptoCurrency
import kotlinx.coroutines.flow.Flow
interface QrScanningEventsRepository {
suspend fun emitResult(type: SourceType, qrCode: String)
fun subscribeToScanningResults(type: SourceType): Flow<String>
fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult
}

View file

@ -1,12 +1,12 @@
package com.tangem.feature.qrscanning.usecase
package com.tangem.domain.qrscanning.usecases
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
internal class EmitQrScannedEventUseCase(
class EmitQrScannedEventUseCase(
private val repository: QrScanningEventsRepository,
) {
suspend operator fun invoke(type: SourceType, qrCode: String): Either<Exception, Unit> {

View file

@ -1,12 +1,11 @@
package com.tangem.feature.qrscanning.usecase
package com.tangem.domain.qrscanning.usecases
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import kotlinx.coroutines.flow.Flow
import java.lang.Exception
class ListenToQrScanningUseCase(
val repository: QrScanningEventsRepository,

View file

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

View file

@ -10,7 +10,6 @@ android {
namespace = "com.tangem.feature.qrscanning.impl"
}
dependencies {
/** Core */
@ -40,8 +39,13 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Api */
implementation(projects.features.qrScanning.api)
/** Domain */
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.ui)

View file

@ -1,27 +0,0 @@
package com.tangem.feature.qrscanning.di
import com.tangem.feature.qrscanning.repo.DefaultQrScanningEventsRepository
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object QrScanningModule {
@Provides
@Singleton
fun provideQrScannedEventsRepository(): QrScanningEventsRepository {
return DefaultQrScanningEventsRepository()
}
@Provides
@Singleton
fun provideEmitQrScannedEventUseCase(repository: QrScanningEventsRepository): EmitQrScannedEventUseCase {
return EmitQrScannedEventUseCase(repository)
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.qrscanning.SourceType
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents

View file

@ -1,19 +0,0 @@
package com.tangem.feature.qrscanning.repo
import com.tangem.feature.qrscanning.SourceType
import kotlinx.coroutines.flow.*
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
private data class QrScanningEvent(val type: SourceType, val qrCode: String)
private val scannedEvents = MutableSharedFlow<QrScanningEvent>()
override suspend fun emitResult(type: SourceType, qrCode: String) {
scannedEvents.emit(QrScanningEvent(type, qrCode))
}
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
.filter { it.type == type }
.map { it.qrCode }
}

View file

@ -1,11 +0,0 @@
package com.tangem.feature.qrscanning.repo
import com.tangem.feature.qrscanning.SourceType
import kotlinx.coroutines.flow.Flow
interface QrScanningEventsRepository {
suspend fun emitResult(type: SourceType, qrCode: String)
fun subscribeToScanningResults(type: SourceType): Flow<String>
}

View file

@ -1,7 +1,7 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.activity.result.ActivityResultLauncher
import com.tangem.feature.qrscanning.SourceType
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import kotlinx.coroutines.CoroutineScope
import kotlin.properties.Delegates

View file

@ -1,8 +1,8 @@
package com.tangem.feature.qrscanning.viewmodel
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.launch

View file

@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.feature.qrscanning.QrScanningRouter.Companion.NETWORK_KEY
import com.tangem.feature.qrscanning.QrScanningRouter.Companion.SOURCE_KEY
import com.tangem.feature.qrscanning.SourceType
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.presentation.QrScanningStateController

View file

@ -69,12 +69,13 @@ dependencies {
implementation(projects.domain.card)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
/** Feature modules */
implementation(projects.features.send.api)
implementation(projects.features.tokendetails.api)
implementation(projects.features.qrScanning.api)
implementation(projects.features.qrScanning.impl)
/** DI */
implementation(deps.hilt.android)

View file

@ -5,10 +5,10 @@ import androidx.fragment.app.Fragment
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.core.navigation.ReduxNavController
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.SourceType
import com.tangem.features.send.impl.presentation.SendFragment
import com.tangem.features.tokendetails.navigation.TokenDetailsRouter

View file

@ -13,8 +13,8 @@ import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.state.StateRouter

View file

@ -13,6 +13,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
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.ReduxStateHolder
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.wallets.models.UserWallet
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.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
@ -75,12 +79,12 @@ internal class SendViewModel @Inject constructor(
private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase,
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val parseSharedAddressUseCase: ParseSharedAddressUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val reduxStateHolder: ReduxStateHolder,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val parseQrCodeUseCase: ParseQrCodeUseCase,
currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
@ -178,6 +182,7 @@ internal class SendViewModel @Inject constructor(
private var recipientsJobHolder = JobHolder()
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
private var memoValidationJobHolder = JobHolder()
private var sendNotificationsJobHolder = JobHolder()
private var feeNotificationsJobHolder = JobHolder()
private var qrScannerJobHolder = JobHolder()
@ -509,13 +514,14 @@ internal class SendViewModel @Inject constructor(
// region recipient state clicks
fun onRecipientAddressScanned(address: String) {
viewModelScope.launch(dispatchers.main) {
parseSharedAddressUseCase(address, cryptoCurrency.network).fold(
parseQrCodeUseCase(address, cryptoCurrency).fold(
ifRight = { parsedCode ->
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
},
ifLeft = {
onRecipientAddressValueChange(address, EnterAddressSource.QRCode)
Timber.w(it)
},
)
@ -543,7 +549,7 @@ internal class SendViewModel @Inject constructor(
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
}
}.saveIn(addressValidationJobHolder)
}.saveIn(memoValidationJobHolder)
}
private suspend fun validateAddress(value: String): Boolean {

View file

@ -85,7 +85,7 @@ leakcanary = "2.13"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "develop-514"
tangemBlockchainSdk = "develop-528"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-332"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -139,6 +139,8 @@ include(":domain:analytics")
include(":domain:visa")
include(":domain:onboarding")
include(":domain:feedback")
include(":domain:qr-scanning")
include(":domain:qr-scanning:models")
// endregion Domain modules
// region Data modules
@ -158,4 +160,5 @@ include(":data:visa")
include(":data:promo")
include(":data:onboarding")
include(":data:feedback")
include(":data:qr-scanning")
// endregion Data modules