Updated on 2026-08-14

This commit is contained in:
Tangem 2024-01-11 20:50:18 +03:00
parent 13fcfb8728
commit b723940318
16 changed files with 182 additions and 22 deletions

View file

@ -113,4 +113,16 @@ internal object WalletsDomainModule {
): ValidateWalletMemoUseCase {
return ValidateWalletMemoUseCase(walletAddressServiceRepository = walletAddressServiceRepository)
}
@Provides
@ViewModelScoped
fun providesParseSharedAddressUseCase(
walletAddressServiceRepository: WalletAddressServiceRepository,
dispatchers: CoroutineDispatcherProvider,
): ParseSharedAddressUseCase {
return ParseSharedAddressUseCase(
walletAddressServiceRepository = walletAddressServiceRepository,
dispatchers = dispatchers,
)
}
}

View file

@ -1,11 +1,14 @@
package com.tangem.data.wallets
import android.net.Uri
import androidx.core.text.isDigitsOnly
import com.tangem.blockchain.blockchains.near.NearWalletManager
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.ParsedQrCode
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.errors.ParsedQrCodeErrors
import com.tangem.domain.wallets.repository.WalletAddressServiceRepository
import java.math.BigInteger
@ -42,6 +45,39 @@ class DefaultWalletAddressServiceRepository(
}
}
override suspend fun parseSharedAddress(input: String, network: Network): ParsedQrCode {
val blockchain = Blockchain.fromId(network.id.value)
val addressSchemeSplit = when (blockchain) {
Blockchain.BitcoinCash, Blockchain.Kaspa -> listOf(input)
else -> input.split(":")
}
val noSchemeAddress = when (addressSchemeSplit.size) {
1 -> { // no scheme
input
}
2 -> { // scheme
if (blockchain.validateShareScheme(addressSchemeSplit[0])) {
addressSchemeSplit[1]
} else {
// to preserve old logic
return ParsedQrCode(address = input)
}
}
else -> { // invalid URI
throw ParsedQrCodeErrors.InvalidUriError
}
}
val uri = Uri.parse(noSchemeAddress)
val address = uri.host ?: noSchemeAddress
val amount = uri.getQueryParameter("amount")?.toBigDecimalOrNull()
return ParsedQrCode(
address = address,
amount = amount,
)
}
private fun Blockchain.isNear(): Boolean {
return this == Blockchain.Near || this == Blockchain.NearTestnet
}

View file

@ -0,0 +1,9 @@
package com.tangem.domain.wallets.models
import java.math.BigDecimal
data class ParsedQrCode(
val address: String,
val memo: String? = null,
val amount: BigDecimal? = null,
)

View file

@ -0,0 +1,6 @@
package com.tangem.domain.wallets.models.errors
sealed class ParsedQrCodeErrors : Throwable() {
object InvalidUriError : ParsedQrCodeErrors()
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.wallets.repository
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.ParsedQrCode
import com.tangem.domain.wallets.models.UserWalletId
/**
@ -11,4 +12,6 @@ interface WalletAddressServiceRepository {
suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean
fun validateMemo(network: Network, memo: String): Boolean
suspend fun parseSharedAddress(input: String, network: Network): ParsedQrCode
}

View file

@ -0,0 +1,22 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.ParsedQrCode
import com.tangem.domain.wallets.repository.WalletAddressServiceRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
class ParseSharedAddressUseCase(
private val walletAddressServiceRepository: WalletAddressServiceRepository,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(input: String, network: Network): Either<Throwable, ParsedQrCode> {
return withContext(dispatchers.io) {
Either.catch {
walletAddressServiceRepository.parseSharedAddress(input, network)
}
}
}
}

View file

@ -40,6 +40,9 @@ internal class QrScanningFragment : ComposeFragment() {
private val viewModel by viewModels<QrScanningViewModel>()
private var cameraExecutor: ExecutorService by Delegates.notNull()
private val analyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(viewModel::onQrScanned)
}
private val cameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (!it) parentFragmentManager.popBackStack()
@ -66,7 +69,7 @@ internal class QrScanningFragment : ComposeFragment() {
}
QrScanningContent(
executor = { cameraExecutor },
analyzer = { MLKitBarcodeAnalyzer(viewModel::onQrScanned) },
analyzer = { analyzer },
uiState = viewModel.uiState,
)
setFitSystemWindows(fit = false)

View file

@ -11,13 +11,13 @@ import javax.annotation.concurrent.Immutable
class MLKitBarcodeAnalyzer(private val onScanned: (String) -> Unit) : ImageAnalysis.Analyzer {
private var isScanning: Boolean = false
private val scanner = BarcodeScanning.getClient()
@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage != null && !isScanning) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
val scanner = BarcodeScanning.getClient()
isScanning = true
scanner.process(image)

View file

@ -38,11 +38,16 @@ internal class QrScanningViewModel @Inject constructor(
var uiState: QrScanningState by mutableStateOf(factory.getInitialState(source, network))
private set
private var isScanned = false
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
router.popBackStack()
if (!isScanned) {
router.popBackStack()
isScanned = true
}
viewModelScope.launch(dispatcher.main) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}

View file

@ -58,11 +58,12 @@ dependencies {
implementation(projects.domain.txhistory.models)
implementation(projects.domain.transaction)
implementation(projects.domain.card)
implementation(projects.domain.demo)
/** 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

@ -7,6 +7,8 @@ import com.tangem.core.navigation.NavigationAction
import com.tangem.core.navigation.ReduxNavController
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
@ -31,4 +33,16 @@ internal class DefaultSendRouter(
),
)
}
override fun openQrCodeScanner(network: String) {
reduxNavController.navigate(
action = NavigationAction.NavigateTo(
screen = AppScreen.QrScanning,
bundle = bundleOf(
QrScanningRouter.SOURCE_KEY to SourceType.SEND,
QrScanningRouter.NETWORK_KEY to network,
),
),
)
}
}

View file

@ -11,4 +11,7 @@ interface InnerSendRouter : SendRouter {
/** Open token details screen by [userWalletId] and [currency] */
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency)
/** Open QR code scanner screen */
fun openQrCodeScanner(network: String)
}

View file

@ -4,16 +4,25 @@ import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
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.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.ui.SendScreen
import com.tangem.features.send.impl.presentation.viewmodel.SendViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import javax.inject.Inject
@ -29,6 +38,9 @@ internal class SendFragment : ComposeFragment() {
@Inject
lateinit var router: SendRouter
@Inject
lateinit var listenToQrScanningUseCase: ListenToQrScanningUseCase
private val viewModel by viewModels<SendViewModel>()
private val innerSendRouter: InnerSendRouter
get() = requireNotNull(router as? InnerSendRouter) {
@ -44,6 +56,7 @@ internal class SendFragment : ComposeFragment() {
fragmentManager = WeakReference(parentFragmentManager),
),
)
listenToQrCode()
}
@Composable
@ -60,7 +73,24 @@ internal class SendFragment : ComposeFragment() {
super.onDestroy()
}
private fun listenToQrCode() {
lifecycleScope.launch {
listenToQrScanningUseCase(SourceType.SEND)
.getOrElse { emptyFlow() }
.flowWithLifecycle(this@SendFragment.lifecycle, minActiveState = Lifecycle.State.CREATED)
.collect {
delay(QR_SCAN_DELAY)
// 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
viewModel.onRecipientAddressScanned(it)
}
}
}
companion object {
private const val QR_SCAN_DELAY = 200L
/** Create send fragment instance */
fun create(): SendFragment = SendFragment()

View file

@ -94,11 +94,14 @@ internal class SendStateFactory(
currentState = MutableStateFlow(SendUiStateType.Amount),
)
fun getReadyState(): SendUiState = currentStateProvider().copy(
amountState = amountStateConverter.convert(Unit),
recipientState = recipientStateConverter.convert(Unit),
feeState = feeStateConverter.convert(Unit),
)
fun getReadyState(): SendUiState {
val state = currentStateProvider()
return state.copy(
amountState = state.amountState ?: amountStateConverter.convert(Unit),
recipientState = state.recipientState ?: recipientStateConverter.convert(Unit),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
)
}
//endregion
//region amount state clicks
@ -160,9 +163,8 @@ internal class SendStateFactory(
isValidating = false,
addressTextField = recipientState.addressTextField.copy(
error = when {
!isValidAddress || isAddressInWallet -> resourceReference(
R.string.send_recipient_address_error,
)
!isValidAddress -> resourceReference(R.string.send_recipient_address_error)
isAddressInWallet -> resourceReference(R.string.send_error_address_same_as_wallet)
else -> null
},
isError = value.isNotEmpty() && !isValidAddress || isAddressInWallet,

View file

@ -6,7 +6,6 @@ import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
internal class SendAmountFieldChangeConverter(
@ -106,8 +105,7 @@ internal class SendAmountFieldChangeConverter(
var trimmedValue = this
if (length > 1 && firstOrNull() == '0' && get(1).isDigit()) trimmedValue = drop(1)
val separatorChar = DecimalFormatSymbols.getInstance().decimalSeparator.toString()
return trimmedValue.replace(TRIM_REGEX.toRegex(), separatorChar)
return trimmedValue.replace(TRIM_REGEX.toRegex(), ".")
}
companion object {

View file

@ -26,14 +26,15 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
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.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.domain.wallets.usecase.*
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.SendNotificationFactory
import com.tangem.features.send.impl.presentation.state.SendStateFactory
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.getFee
@ -67,6 +68,7 @@ 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,
getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
validateWalletMemoUseCase: ValidateWalletMemoUseCase,
@ -116,6 +118,7 @@ internal class SendViewModel @Inject constructor(
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
private var sendNotificationsJobHolder = JobHolder()
private var qrScannerJobHolder = JobHolder()
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
@ -326,9 +329,7 @@ internal class SendViewModel @Inject constructor(
override fun onNextClick() = stateRouter.onNextClick()
override fun onPrevClick() = stateRouter.onPrevClick()
override fun onQrCodeScanClick() {
// TODO Add QR code scanning
}
override fun onQrCodeScanClick() = innerRouter.openQrCodeScanner(cryptoCurrency.network.name)
override fun onTokenDetailsClick(userWalletId: UserWalletId, currency: CryptoCurrency) =
innerRouter.openTokenDetails(userWalletId, currency)
@ -355,6 +356,21 @@ internal class SendViewModel @Inject constructor(
// endregion
// region recipient state clicks
fun onRecipientAddressScanned(address: String) {
viewModelScope.launch(dispatchers.main) {
parseSharedAddressUseCase(address, cryptoCurrency.network).fold(
ifRight = { parsedCode ->
onRecipientAddressValueChange(parsedCode.address)
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
},
ifLeft = {
Timber.w(it)
},
)
}.saveIn(qrScannerJobHolder)
}
override fun onRecipientAddressValueChange(value: String) {
uiState = stateFactory.onRecipientAddressValueChange(value)
viewModelScope.launch(dispatchers.main) {