Updated on 2026-08-14

This commit is contained in:
Tangem 2026-08-13 17:52:05 +04:00
parent cd266b50f5
commit 00126b9d36
21 changed files with 711 additions and 16 deletions

View file

@ -364,6 +364,8 @@ internal class SendConfirmModel @Inject constructor(
private fun verifyAndSendTransaction() {
val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return
// A blank recipient is deliberately not filtered out here: returning early would leave `isSending` on
// forever. It is rejected while the transaction is built, so it surfaces as the regular error alert.
val destination = destinationUM?.addressTextField?.actualAddress ?: return
val memo = destinationUM?.memoTextField?.value
val fee = feeUMV2?.selectedFeeItem?.fee

View file

@ -26,6 +26,15 @@ internal class SendDestinationAlertFactory @Inject constructor(
)
}
fun showUnrecognizedQrCodeAlert() {
messageSender.send(
DialogMessage(
title = resourceReference(id = R.string.qr_scanner_error_unrecognized_title),
message = resourceReference(id = R.string.qr_scanner_error_unrecognized_message),
),
)
}
fun showRecipientBackupErrorAlert(onContactSupport: () -> Unit) {
messageSender.send(
DialogMessage(

View file

@ -33,6 +33,7 @@ import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
@ -314,8 +315,14 @@ internal class SendDestinationModel @Inject constructor(
.launchIn(modelScope)
}
private fun onQrCodeScanned(address: String) {
val parsedQrCode = parseQrCodeUseCase(address, cryptoCurrency).getOrNull() ?: return
private fun onQrCodeScanned(qrCode: String) {
val parsedQrCode = parseQrCodeUseCase(qrCode, cryptoCurrency).getOrNull()
if (parsedQrCode == null || parsedQrCode.address.isBlank()) {
sendDestinationAlertFactory.showUnrecognizedQrCodeAlert()
return
}
_uiState.update(
SendDestinationPredefinedStateTransformer(
address = parsedQrCode.address,
@ -443,6 +450,8 @@ internal class SendDestinationModel @Inject constructor(
allowSelfSend = params.isAllowSelfSend,
)
notifyIfQrCodeUnrecognized(type = type, addressValidationResult = addressValidationResult)
if (addressValidationResult.isRight()) {
val problematicWalletId = resolveBackupProblematicWallet(address)
if (problematicWalletId != null) {
@ -503,6 +512,21 @@ internal class SendDestinationModel @Inject constructor(
}.saveIn(validationJobHolder)
}
/**
* The scanned code carries nothing this network accepts as a recipient a link, an address of another network,
* a burn address. Reported as an alert, because the inline field error is easy to miss right after the camera
* closes, and a QR that "filled something in" reads as a successfully scanned recipient.
*/
private fun notifyIfQrCodeUnrecognized(
type: EnterAddressSource?,
addressValidationResult: AddressValidationResult,
) {
if (type != EnterAddressSource.QRCode) return
if (addressValidationResult.leftOrNull() != AddressValidation.Error.InvalidAddress) return
sendDestinationAlertFactory.showUnrecognizedQrCodeAlert()
}
private fun recognizeContact(type: EnterAddressSource?, isValidAddress: Boolean, address: String) {
if (type == null || type == EnterAddressSource.Contact) return
val contact = if (isValidAddress) findContactByAddress(address) else null

View file

@ -155,6 +155,11 @@ private fun LazyListScope.addressItem(
onAddressChange(it, EnterAddressSource.PasteButton)
}
},
onClearClick = {
GlobalMultipleClickPreventer.processEvent {
onAddressChange("", EnterAddressSource.InputField)
}
},
onQrCodeClick = onQrCodeClick,
isError = isError,
isLoading = isValidating,

View file

@ -87,7 +87,7 @@ internal fun TextFieldWithPaste(
.align(CenterVertically),
) {
CrossIcon(
onClick = onPasteClick,
onClick = { onPasteClick("") },
modifier = Modifier.testTag(SendAddressScreenTestTags.DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON),
)
}

View file

@ -26,6 +26,7 @@ import com.tangem.domain.models.network.CryptoCurrencyAddress
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
@ -286,7 +287,7 @@ internal class SendDestinationModelTest {
inner class QrScan {
@Test
fun `GIVEN unparseable QR WHEN scanned THEN do NOT validate`() = runTest {
fun `GIVEN unparseable QR WHEN scanned THEN do NOT validate AND show unrecognized alert`() = runTest {
// Arrange
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
@ -307,6 +308,83 @@ internal class SendDestinationModelTest {
any()
)
}
verify(exactly = 1) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR without address WHEN scanned THEN keep entered recipient AND show unrecognized alert`() =
runTest {
// Arrange — the QR parses but yields no address (e.g. an ERC-681 URI for another token)
val qrFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns QrResult(address = "").right()
val sut = buildModel(initialState = contentState(address = "0xEntered"))
advanceUntilIdle()
// Act
qrFlow.tryEmit("rawQr")
advanceUntilIdle()
// Assert — a QR with nothing usable must not wipe what the user already has
assertThat(content(sut).addressTextField.value).isEqualTo("0xEntered")
verify(exactly = 1) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR address invalid for network WHEN scanned THEN show unrecognized alert`() = runTest {
// Arrange — a scanned link or a foreign-network address the current network rejects
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns QrResult(address = "exchange.com").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("exchange.com"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Error.InvalidAddress.left()
// Act
buildModel()
advanceUntilIdle()
// Assert
verify(exactly = 1) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR address already in wallet WHEN scanned THEN do NOT show unrecognized alert`() = runTest {
// Arrange — self-send is reported inline on the field, the QR itself was read fine
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns QrResult(address = "0xSelf").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("0xSelf"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Error.AddressInWallet.left()
// Act
buildModel()
advanceUntilIdle()
// Assert
verify(exactly = 0) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR with valid address WHEN scanned THEN fill recipient without alert`() = runTest {
// Arrange
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns
QrResult(address = "0xValid", memo = "42").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("0xValid"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
val sut = buildModel(initialState = contentState(address = "", memo = ""))
// Act
advanceUntilIdle()
// Assert
assertThat(content(sut).addressTextField.value).isEqualTo("0xValid")
assertThat(content(sut).memoTextField?.value).isEqualTo("42")
verify(exactly = 0) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
}