Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-18 12:36:46 +05:00
parent 26201c5c0b
commit 0be1358760
39 changed files with 581 additions and 124 deletions

View file

@ -1,6 +1,8 @@
package com.tangem.tap.di.domain
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.DeleteSavedAccessCodesUseCase
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.card.ResetCardUseCase
import com.tangem.domain.card.SetCardWasScannedUseCase
import com.tangem.domain.card.repository.CardRepository
@ -32,6 +34,16 @@ internal object CardDomainModule {
@Singleton
fun provideIsDemoCardUseCase(): IsDemoCardUseCase = IsDemoCardUseCase(config = DemoConfig)
@Provides
@Singleton
fun provideBackupValidator(): BackupValidator = BackupValidator()
@Provides
@Singleton
fun provideIsWalletBackupProblematicUseCase(backupValidator: BackupValidator): IsWalletBackupProblematicUseCase {
return IsWalletBackupProblematicUseCase(backupValidator = backupValidator)
}
@Provides
@Singleton
fun provideDerivePublicKeysUseCase(derivationsRepository: DerivationsRepository): DerivePublicKeysUseCase {

View file

@ -3,6 +3,7 @@ package com.tangem.tap.di.domain
import android.content.Context
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.repository.FeedbackRepository
import dagger.Module
@ -36,4 +37,16 @@ internal object FeedbackDomainModule {
fun provideSaveBlockchainErrorUseCase(feedbackRepository: FeedbackRepository): SaveBlockchainErrorUseCase {
return SaveBlockchainErrorUseCase(feedbackRepository = feedbackRepository)
}
@Provides
@Singleton
fun provideSendBackupProblemEmailUseCase(
getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
): SendBackupProblemEmailUseCase {
return SendBackupProblemEmailUseCase(
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
)
}
}

View file

@ -31,6 +31,8 @@ dependencies {
implementation(projects.domain.onramp.models)
implementation(projects.domain.offramp)
implementation(projects.domain.demo)
implementation(projects.domain.card)
implementation(projects.domain.feedback)
implementation(deps.lifecycle.compose)
implementation(deps.compose.foundation)

View file

@ -12,9 +12,12 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
@ -23,9 +26,9 @@ import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
class TokenActionsHandler @AssistedInject constructor(
@ -39,6 +42,8 @@ class TokenActionsHandler @AssistedInject constructor(
@Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit,
@Assisted private val coroutineScope: CoroutineScope,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val messageSender: UiMessageSender,
) {
@ -47,6 +52,8 @@ class TokenActionsHandler @AssistedInject constructor(
}
fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: CryptoCurrencyData) {
if (isTopUpBlockedByBackupError(action, cryptoCurrencyData.userWallet)) return
onHandleQuickAction(
HandledQuickAction(
action = action,
@ -80,6 +87,21 @@ class TokenActionsHandler @AssistedInject constructor(
}
}
private fun isTopUpBlockedByBackupError(action: TokenActionsBSContentUM.Action, userWallet: UserWallet): Boolean {
val isBlockedAction = action == TokenActionsBSContentUM.Action.Buy ||
action == TokenActionsBSContentUM.Action.Receive ||
action == TokenActionsBSContentUM.Action.Exchange
if (!isBlockedAction) return false
if (!isWalletBackupProblematicUseCase(userWallet)) return false
messageSender.send(
Dialogs.backupErrorAddFundsDisabled(
onContactSupport = { coroutineScope.launch { sendBackupProblemEmailUseCase(userWallet.walletId) } },
),
)
return true
}
private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean {
val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId)
val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action)

View file

@ -41,6 +41,15 @@ sealed class NotificationUM(val config: NotificationConfig) {
subtitle = resourceReference(R.string.send_notification_invalid_amount_text),
)
data class DestinationBackupError(val onContactSupport: () -> Unit) : Error(
title = resourceReference(R.string.warning_backup_error_add_funds_title),
subtitle = resourceReference(R.string.warning_backup_error_add_funds_message),
buttonState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_contact_support),
onClick = onContactSupport,
),
)
data class MinimumAmountError(val amount: String) : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(

View file

@ -4,6 +4,7 @@ import android.content.res.Configuration
import androidx.annotation.DrawableRes
import coil.compose.SubcomposeAsyncImage
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
@ -28,6 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
@ -61,6 +63,7 @@ fun Notification(
titleColor: Color = TangemTheme.colors.text.primary1,
subtitleColor: Color = TangemTheme.colors.text.tertiary,
containerColor: Color? = null,
borderColor: Color? = null,
iconTint: Color? = when (config.iconTint) {
NotificationConfig.IconTint.Unspecified -> null
NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent
@ -75,6 +78,7 @@ fun Notification(
onCloseClick = config.onCloseClick,
modifier = modifier,
containerColor = containerColor,
borderColor = borderColor,
isEnabled = isEnabled,
) {
MainContent(
@ -100,6 +104,7 @@ internal fun NotificationBaseContainer(
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
containerColor: Color? = null,
borderColor: Color? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val tempContainerColor by rememberUpdatedState(
@ -118,6 +123,7 @@ internal fun NotificationBaseContainer(
enabled = onClick != null && isEnabled,
shape = TangemTheme.shapes.roundedCornersXMedium,
color = containerColor ?: tempContainerColor,
border = borderColor?.let { BorderStroke(width = 1.dp, color = it) },
) {
Box {
Column(

View file

@ -79,6 +79,24 @@ object Dialogs {
)
}
/**
* Dialog shown when adding funds (buy / receive / swap) is blocked because the wallet has a backup problem.
* Centralizes the wording and button behavior reused across wallet actions, token details, markets and swap.
*
* @param onContactSupport lambda invoked when the "Contact support" action is clicked
*/
fun backupErrorAddFundsDisabled(onContactSupport: () -> Unit): DialogMessage = DialogMessage(
title = resourceReference(R.string.warning_backup_error_add_funds_title),
message = resourceReference(R.string.warning_backup_error_add_funds_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_contact_support),
onClick = onContactSupport,
)
},
secondActionBuilder = { cancelAction() },
)
/**
* Universal error dialog
*/

View file

@ -72,6 +72,7 @@ object TangemColorPalette {
// region Amaranth
val Amaranth = Color(0xFFFF3333)
val Amaranth_50 = Color(0x80FF3333)
val Amaranth_30 = Color(0x4DFF3333)
val Amaranth_20 = Color(0x33FF3333)
val Amaranth_10 = Color(0x1AFF3333)
// endregion Amaranth

View file

@ -12,6 +12,7 @@ android {
}
dependencies {
api(projects.domain.account)
api(projects.domain.card)
api(projects.domain.core)
api(projects.domain.common)
api(projects.domain.express)

View file

@ -8,6 +8,7 @@ import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.express.ExpressServiceFetcher
@ -24,6 +25,7 @@ import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -50,6 +52,20 @@ internal object AccountStatusUseCaseModule {
)
}
@Provides
@Singleton
fun provideGetBackupProblematicWalletForAddressUseCase(
getAccountCurrencyByAddressUseCase: GetAccountCurrencyByAddressUseCase,
getUserWalletUseCase: GetUserWalletUseCase,
isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
): GetBackupProblematicWalletForAddressUseCase {
return GetBackupProblematicWalletForAddressUseCase(
getAccountCurrencyByAddressUseCase = getAccountCurrencyByAddressUseCase,
getUserWalletUseCase = getUserWalletUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
)
}
@Provides
@Singleton
fun provideIsAccountsModeEnabledUseCase(

View file

@ -0,0 +1,24 @@
package com.tangem.domain.account.status.usecase
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
/**
* Resolves [address] to one of the user's own wallets and returns its [UserWalletId] when that wallet
* has a backup error (e.g. a CardLinked card), or `null` otherwise.
*
* Shared by the send and swap flows to block topping up a wallet with a backup problem.
*/
class GetBackupProblematicWalletForAddressUseCase(
private val getAccountCurrencyByAddressUseCase: GetAccountCurrencyByAddressUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
) {
suspend operator fun invoke(address: String): UserWalletId? {
val walletId = getAccountCurrencyByAddressUseCase(address).getOrNull()?.account?.userWalletId ?: return null
val wallet = getUserWalletUseCase(walletId).getOrNull() ?: return null
return walletId.takeIf { isWalletBackupProblematicUseCase(wallet) }
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.domain.account.status.usecase
import arrow.core.left
import arrow.core.none
import arrow.core.right
import arrow.core.some
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.status.model.AccountCryptoCurrency
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetBackupProblematicWalletForAddressUseCaseTest {
private val getAccountCurrencyByAddressUseCase: GetAccountCurrencyByAddressUseCase = mockk()
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk()
private val useCase = GetBackupProblematicWalletForAddressUseCase(
getAccountCurrencyByAddressUseCase = getAccountCurrencyByAddressUseCase,
getUserWalletUseCase = getUserWalletUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
)
@AfterEach
fun tearDown() {
clearMocks(getAccountCurrencyByAddressUseCase, getUserWalletUseCase, isWalletBackupProblematicUseCase)
}
@Test
fun `returns null when address is not resolved to an account`() = runTest {
coEvery { getAccountCurrencyByAddressUseCase(ADDRESS) } returns none()
assertThat(useCase(ADDRESS)).isNull()
}
@Test
fun `returns null when destination wallet is not found`() = runTest {
coEvery { getAccountCurrencyByAddressUseCase(ADDRESS) } returns accountCurrency.some()
every { getUserWalletUseCase(walletId) } returns GetUserWalletError.UserWalletNotFound.left()
assertThat(useCase(ADDRESS)).isNull()
}
@Test
fun `returns null when destination wallet is not problematic`() = runTest {
coEvery { getAccountCurrencyByAddressUseCase(ADDRESS) } returns accountCurrency.some()
every { getUserWalletUseCase(walletId) } returns userWallet.right()
every { isWalletBackupProblematicUseCase(userWallet) } returns false
assertThat(useCase(ADDRESS)).isNull()
}
@Test
fun `returns wallet id when destination wallet is problematic`() = runTest {
coEvery { getAccountCurrencyByAddressUseCase(ADDRESS) } returns accountCurrency.some()
every { getUserWalletUseCase(walletId) } returns userWallet.right()
every { isWalletBackupProblematicUseCase(userWallet) } returns true
assertThat(useCase(ADDRESS)).isEqualTo(walletId)
}
private companion object {
const val ADDRESS = "0x1234567890abcdef"
val walletId = UserWalletId("011")
val userWallet = mockk<UserWallet>()
val accountCurrency = mockk<AccountCryptoCurrency> {
every { account } returns mockk { every { userWalletId } returns walletId }
}
}
}

View file

@ -4,7 +4,7 @@ import com.tangem.common.card.EllipticCurve
import com.tangem.domain.card.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO
object BackupValidator {
class BackupValidator {
fun isValidFull(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO) && validateCurves(cardDTO)

View file

@ -0,0 +1,23 @@
package com.tangem.domain.card
import com.tangem.domain.models.wallet.UserWallet
/**
* Single source of truth for "this wallet has a backup problem".
*
* A wallet is considered problematic when it is backed by a physical card whose backup was not
* completed correctly (e.g. a card stuck in the `CardLinked` state) or when a backup error has
* been persisted for it. Such wallets must show a prominent warning and have all top-up
* operations blocked until the user resolves the issue with support.
*
* Local detection only. Can later be backed by a backend flag without changing call sites.
*/
class IsWalletBackupProblematicUseCase(
private val backupValidator: BackupValidator,
) {
operator fun invoke(userWallet: UserWallet): Boolean {
if (userWallet !is UserWallet.Cold) return false
return userWallet.hasBackupError || !backupValidator.isValidBackupStatus(userWallet.scanResponse.card)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.domain.card
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWallet
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class IsWalletBackupProblematicUseCaseTest {
private val backupValidator: BackupValidator = mockk()
private val useCase = IsWalletBackupProblematicUseCase(backupValidator = backupValidator)
@BeforeEach
fun setup() {
clearMocks(backupValidator)
}
@Test
fun `returns false for hot wallet`() {
val wallet = mockk<UserWallet.Hot>()
assertThat(useCase(wallet)).isFalse()
}
@Test
fun `returns true for cold wallet with persisted backup error`() {
val wallet = coldWallet(hasBackupError = true)
every { backupValidator.isValidBackupStatus(any()) } returns true
assertThat(useCase(wallet)).isTrue()
}
@Test
fun `returns true for cold wallet with invalid backup status`() {
val wallet = coldWallet(hasBackupError = false)
every { backupValidator.isValidBackupStatus(any()) } returns false
assertThat(useCase(wallet)).isTrue()
}
@Test
fun `returns false for cold wallet without backup error and valid backup status`() {
val wallet = coldWallet(hasBackupError = false)
every { backupValidator.isValidBackupStatus(any()) } returns true
assertThat(useCase(wallet)).isFalse()
}
private fun coldWallet(hasBackupError: Boolean): UserWallet.Cold = mockk {
every { this@mockk.hasBackupError } returns hasBackupError
every { scanResponse } returns mockk {
every { card } returns mockk()
}
}
}

View file

@ -17,4 +17,7 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.visa.models)
implementation(projects.domain.feedback.models)
/** Testing libraries */
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.feedback
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.wallet.UserWalletId
class SendBackupProblemEmailUseCase(
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
) {
suspend operator fun invoke(userWalletId: UserWalletId) {
val metaInfo = getWalletMetaInfoUseCase(userWalletId).getOrNull() ?: return
sendFeedbackEmailUseCase(type = FeedbackEmailType.BackupProblem(walletMetaInfo = metaInfo))
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.domain.feedback
import arrow.core.left
import arrow.core.right
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SendBackupProblemEmailUseCaseTest {
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk()
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true)
private val useCase = SendBackupProblemEmailUseCase(
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
)
@AfterEach
fun tearDown() {
clearMocks(getWalletMetaInfoUseCase, sendFeedbackEmailUseCase)
}
@Test
fun `does not send email when wallet meta info is unavailable`() = runTest {
coEvery { getWalletMetaInfoUseCase(walletId) } returns Throwable().left()
useCase(walletId)
coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
}
@Test
fun `sends backup problem email when wallet meta info is available`() = runTest {
val metaInfo = mockk<WalletMetaInfo>()
coEvery { getWalletMetaInfoUseCase(walletId) } returns metaInfo.right()
useCase(walletId)
coVerify(exactly = 1) {
sendFeedbackEmailUseCase(FeedbackEmailType.BackupProblem(walletMetaInfo = metaInfo))
}
}
private companion object {
val walletId = UserWalletId("011")
}
}

View file

@ -14,6 +14,7 @@ sealed class AddressValidation {
sealed class Error : AddressValidation() {
data object AddressInWallet : Error()
data object InvalidAddress : Error()
data object RecipientWalletBackupError : Error()
data class DataError(val throwable: Throwable) : Error()
}
}

View file

@ -1,47 +0,0 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.card.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO
import javax.inject.Inject
class BackupValidator @Inject constructor() {
fun isValidFull(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO) && validateCurves(cardDTO)
}
fun isValidBackupStatus(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO)
}
private fun validateCurves(cardDTO: CardDTO): Boolean {
val config = CardConfig.createConfig(cardDTO)
// / Since the curve `bls12381_G2_AUG` was added later into first generation of wallets,
// we cannot determine whether this curve is missing due to an error or because the user
// did not want to recreate the wallet.
val expectedCurves = config.mandatoryCurves
.filterNot { it == EllipticCurve.Bls12381G2Aug }
val curves = cardDTO.wallets.map { it.curve }
for (expectedCurve in expectedCurves) {
val cardCurvesCount = curves.count { it == expectedCurve }
// missing curve
if (cardCurvesCount == 0) {
return false
}
// duplicated curve
if (cardCurvesCount > 1) {
return false
}
}
return true
}
private fun validateBackupStatus(cardDTO: CardDTO): Boolean {
val backupStatus = cardDTO.backupStatus
backupStatus ?: return true // for card with null backup status, validation should always returns true
return backupStatus !is CardDTO.BackupStatus.CardLinked
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
@ -212,7 +213,6 @@ internal class MultiWalletFinalizeModel @Inject constructor(
backupService.proceedBackup(iconScanRes = iconScanRes) { result ->
when (result) {
is CompletionResult.Success -> {
val backupValidator = BackupValidator()
if (backupValidator.isValidBackupStatus(CardDTO(result.data)).not()) {
hasWalletBackupError = true
}

View file

@ -6,6 +6,7 @@ import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
@ -97,6 +98,7 @@ internal class MultiWalletFinalizeModelTest {
every { backupService.backupCardsBatchIds } returns listOf(NON_RING_BATCH_ID, NON_RING_BATCH_ID)
every { backupService.currentState } returns BackupService.State.FinalizingPrimaryCard
coEvery { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } just Runs
every { backupValidator.isValidBackupStatus(any()) } returns true
}
@Test
@ -427,9 +429,7 @@ internal class MultiWalletFinalizeModelTest {
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
)
every { backupService.currentState } returns BackupService.State.FinalizingBackupCard(index = 1)
mockkConstructor(BackupValidator::class)
every { anyConstructed<BackupValidator>().isValidBackupStatus(any()) } returns true
every { backupValidator.isValidBackupStatus(any()) } returns true
val card: Card = mockk(relaxed = true)
val callbackSlot = slot<(CompletionResult<Card>) -> Unit>()
@ -453,8 +453,6 @@ internal class MultiWalletFinalizeModelTest {
Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step)
Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber)
Assertions.assertTrue(events.contains(MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded))
unmockkConstructor(BackupValidator::class)
}
private fun String.lastMaskedExpected(): String {

View file

@ -25,4 +25,17 @@ internal class SendDestinationAlertFactory @Inject constructor(
),
)
}
fun showRecipientBackupErrorAlert(onContactSupport: () -> Unit) {
messageSender.send(
DialogMessage(
title = resourceReference(id = R.string.warning_backup_error_add_funds_title),
message = resourceReference(id = R.string.warning_backup_error_add_funds_message),
firstAction = EventMessageAction(
title = resourceReference(R.string.common_contact_support),
onClick = onContactSupport,
),
),
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.send.subcomponents.destination.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
@ -12,12 +13,15 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.CryptoCurrencyAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
@ -48,6 +52,7 @@ import com.tangem.features.send.subcomponents.destination.model.transformers.Sen
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationStartedTransformer
import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -58,6 +63,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@Stable
@ -79,6 +85,9 @@ internal class SendDestinationModel @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
private val sendDestinationAlertFactory: SendDestinationAlertFactory,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
) : Model(), SendDestinationClickIntents {
private val params: SendDestinationComponentParams = paramsContainer.require()
@ -95,6 +104,8 @@ internal class SendDestinationModel @Inject constructor(
private val validationJobHolder = JobHolder()
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
init {
configDestinationNavigation()
subscribeOnQRScannerResult()
@ -288,17 +299,41 @@ internal class SendDestinationModel @Inject constructor(
}
}
private suspend fun resolveBackupProblematicWallet(address: String): UserWalletId? {
backupProblematicWalletCache.get()?.let { if (it.first == address) return it.second }
return getBackupProblematicWalletForAddressUseCase(address)
.also { backupProblematicWalletCache.set(address to it) }
}
private fun contactBackupSupport(userWalletId: UserWalletId) {
modelScope.launch { sendBackupProblemEmailUseCase(userWalletId) }
}
private fun validate(address: String, memo: String?, type: EnterAddressSource? = null) {
modelScope.launch {
_uiState.update(SendDestinationValidationStartedTransformer)
val addressValidationResult = validateWalletAddressUseCase(
var addressValidationResult = validateWalletAddressUseCase(
userWalletId = userWalletId,
network = cryptoCurrency.network,
address = address,
senderAddresses = senderAddresses.value,
allowSelfSend = params.isAllowSelfSend,
)
if (addressValidationResult.isRight()) {
val problematicWalletId = resolveBackupProblematicWallet(address)
if (problematicWalletId != null) {
addressValidationResult = AddressValidation.Error.RecipientWalletBackupError.left()
if (type != null) {
sendDestinationAlertFactory.showRecipientBackupErrorAlert(
onContactSupport = { contactBackupSupport(problematicWalletId) },
)
}
}
}
val memoValidationResult = validateWalletMemoUseCase(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,

View file

@ -24,14 +24,7 @@ internal class SendDestinationValidationResultTransformer(
val isValidAddress = addressValidationResult.isRight()
val isValidMemo = shouldDisableMemo || memoValidationResult.isRight()
val addressErrorText = addressValidationResult.mapLeft { error ->
when (error) {
is AddressValidation.Error.DataError,
AddressValidation.Error.InvalidAddress,
-> R.string.send_recipient_address_error
AddressValidation.Error.AddressInWallet -> R.string.send_error_address_same_as_wallet
}
}.leftOrNull()
val addressErrorText = resolveAddressErrorText()
val blockchainAddress =
(addressValidationResult.getOrNull() as? AddressValidation.Success.ValidNamedAddress)?.blockchainAddress
@ -61,6 +54,16 @@ internal class SendDestinationValidationResultTransformer(
)
}
private fun resolveAddressErrorText(): Int? = addressValidationResult.mapLeft { error ->
when (error) {
is AddressValidation.Error.DataError,
AddressValidation.Error.InvalidAddress,
-> R.string.send_recipient_address_error
AddressValidation.Error.AddressInWallet -> R.string.send_error_address_same_as_wallet
AddressValidation.Error.RecipientWalletBackupError -> R.string.warning_backup_error_add_funds_message
}
}.leftOrNull()
private fun buildMemoField(
memoField: DestinationTextFieldUM.RecipientMemo?,
isValidMemo: Boolean,

View file

@ -7,7 +7,10 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact
@ -28,6 +31,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@Suppress("LongParameterList")
@ -38,6 +42,8 @@ internal class SwapNotificationsModel @Inject constructor(
private val swapNotificationsUpdateTrigger: DefaultSwapNotificationsUpdateTrigger,
private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger,
private val isMemoRequiredUseCase: IsMemoRequiredUseCase,
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
) : Model() {
@ -47,6 +53,8 @@ internal class SwapNotificationsModel @Inject constructor(
private var notificationData = params.swapNotificationData
private var lastSentErrorKeys: Set<Pair<String, Map<String, String>>> = emptySet()
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
val uiState: StateFlow<ImmutableList<NotificationUM>>
field = MutableStateFlow<ImmutableList<NotificationUM>>(persistentListOf())
@ -73,6 +81,7 @@ internal class SwapNotificationsModel @Inject constructor(
addInsufficientFundsNotification()
addExpressErrorNotification()
addDestinationTagRequiredNotification()
addDestinationBackupErrorNotification()
maybeAddPriceImpactNotification()
}
@ -134,6 +143,25 @@ internal class SwapNotificationsModel @Inject constructor(
}
}
private suspend fun MutableList<NotificationUM>.addDestinationBackupErrorNotification() {
val destinationAddress = notificationData.destinationAddress
if (destinationAddress.isEmpty()) return
val problematicWalletId = resolveBackupProblematicWallet(destinationAddress) ?: return
add(
NotificationUM.Error.DestinationBackupError(
onContactSupport = { modelScope.launch { sendBackupProblemEmailUseCase(problematicWalletId) } },
),
)
}
private suspend fun resolveBackupProblematicWallet(address: String): UserWalletId? {
backupProblematicWalletCache.get()?.let { if (it.first == address) return it.second }
return getBackupProblematicWalletForAddressUseCase(address)
.also { backupProblematicWalletCache.set(address to it) }
}
private fun MutableList<NotificationUM>.addInsufficientFundsNotification() {
val enteredFromAmount = notificationData.enteredFromAmount ?: return
val balance = notificationData.fromCryptoCurrencyStatus?.value?.amount ?: return

View file

@ -56,6 +56,7 @@ dependencies {
implementation(projects.domain.express.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.card)
implementation(projects.domain.visa)
implementation(projects.domain.markets)
implementation(projects.domain.swap)

View file

@ -35,6 +35,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.core.ui.utils.parseToBigDecimal
@ -42,6 +43,7 @@ import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -49,6 +51,7 @@ import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
@ -149,6 +152,8 @@ internal class SwapModel @Inject constructor(
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val swapInteractor: SwapInteractor,
private val swapTransferInteractor: SwapTransferInteractor,
private val swapTransferStateBuilder: SwapTransferStateBuilder,
@ -472,6 +477,12 @@ internal class SwapModel @Inject constructor(
val selectedCurrencyStatus = result.currency
val selectedAccount = result.account.account
if (!isFromDirection && isWalletBackupProblematicUseCase(selectedUserWallet)) {
router.pop()
showBackupErrorAlert(selectedUserWallet.walletId)
return
}
val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = if (isFromDirection) {
SwapCurrencyStatus(
userWallet = selectedUserWallet,
@ -1862,6 +1873,14 @@ internal class SwapModel @Inject constructor(
)
}
private fun showBackupErrorAlert(userWalletId: UserWalletId) {
messageSender.send(
Dialogs.backupErrorAddFundsDisabled(
onContactSupport = { modelScope.launch { sendBackupProblemEmailUseCase(userWalletId) } },
),
)
}
private fun showTransactionErrorAlert(
error: SwapTransactionState.Error,
onSupportClick: (String) -> Unit = ::onFailedTxEmailClick,

View file

@ -16,8 +16,10 @@ import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSy
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -101,6 +103,8 @@ internal abstract class SwapModelTestBase {
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true)
protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getUserCountryUseCase: GetUserCountryUseCase = mockk(relaxed = true)
@ -171,6 +175,8 @@ internal abstract class SwapModelTestBase {
getSwapUiModeUseCase = getSwapUiModeUseCase,
setSwapUiModeUseCase = setSwapUiModeUseCase,
calculateAmountUseCase = calculateAmountUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
)
// region builders

View file

@ -68,6 +68,7 @@ dependencies {
implementation(projects.domain.demo)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.feedback)
implementation(projects.domain.markets.models)
implementation(projects.domain.models)
implementation(projects.domain.notifications.models)

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.tokendetails.impl.R
import javax.inject.Inject
@ -70,4 +71,8 @@ internal class TokenDetailsDialogFactory @Inject constructor(
fun showError(text: TextReference) {
uiMessageSender.send(DialogMessage(message = text))
}
fun showBackupError(onContactSupport: () -> Unit) {
uiMessageSender.send(Dialogs.backupErrorAddFundsDisabled(onContactSupport = onContactSupport))
}
}

View file

@ -53,6 +53,8 @@ import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.models.StatusSource
@ -159,6 +161,8 @@ internal class TokenDetailsModel @Inject constructor(
private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase,
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val associateAssetUseCase: AssociateAssetUseCase,
private val retryIncompleteTransactionUseCase: RetryIncompleteTransactionUseCase,
private val openTrustlineUseCase: OpenTrustlineUseCase,
@ -588,6 +592,7 @@ internal class TokenDetailsModel @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
return
}
if (isTopUpBlockedByBackupError()) return
val status = cryptoCurrencyStatus ?: return
modelScope.launch {
@ -710,6 +715,7 @@ internal class TokenDetailsModel @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
return
}
if (isTopUpBlockedByBackupError()) return
modelScope.launch {
if (needShowYieldSupplyWarning()) {
@ -824,6 +830,7 @@ internal class TokenDetailsModel @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
return
}
if (isTopUpBlockedByBackupError()) return
modelScope.launch {
if (checkYieldSupply && needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)) {
@ -1199,6 +1206,19 @@ internal class TokenDetailsModel @Inject constructor(
return true
}
private fun isTopUpBlockedByBackupError(): Boolean {
if (!isWalletBackupProblematicUseCase(userWallet)) return false
dialogFactory.showBackupError(onContactSupport = ::contactBackupSupport)
return true
}
private fun contactBackupSupport() {
modelScope.launch {
sendBackupProblemEmailUseCase(userWallet.walletId)
}
}
private fun openStaking() {
modelScope.launch {
getStakingAvailabilityUseCase.invokeSync(userWalletId, cryptoCurrency)

View file

@ -11,6 +11,7 @@ import com.tangem.common.ui.tokens.getUnavailabilityReasonText
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.ButtonSupport
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
@ -27,10 +28,12 @@ import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountId
@ -58,6 +61,7 @@ import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
@ -144,6 +148,9 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private val isCryptoCurrencyCouldHideUseCase: IsCryptoCurrencyCouldHideUseCase,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val uiMessageSender: UiMessageSender,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents {
override fun onSendClick(
@ -196,6 +203,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
event?.let { analyticsEventHandler.send(it) }
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
modelScope.launch(dispatchers.main) {
if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) {
stateHolder.hideBottomSheet()
@ -339,6 +348,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
)
if (handleUnavailabilityReason(unavailabilityReason)) return
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
appRouter.push(
AppRoute.Onramp(
@ -363,6 +373,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
)
if (handleUnavailabilityReason(unavailabilityReason)) return
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
modelScope.launch(dispatchers.main) {
if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) {
@ -451,6 +462,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
if (isTopUpBlockedByBackupError(userWalletId)) return
if (!isMultiWalletTokensLoaded()) return
modelScope.launch {
@ -469,6 +481,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
override fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) {
if (isTopUpBlockedByBackupError(userWalletId)) return
onMultiWalletActionClick(
statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId),
route = AppRoute.BuyCrypto(userWalletId = userWalletId),
@ -565,6 +579,26 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
return true
}
private fun isTopUpBlockedByBackupError(userWalletId: UserWalletId): Boolean {
val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return false
if (!isWalletBackupProblematicUseCase(userWallet)) return false
stateHolder.hideBottomSheet()
uiMessageSender.send(
WalletAlertUM.addFundsDisabledForBackupError(
onContactSupport = { contactBackupSupport(userWallet) },
),
)
return true
}
private fun contactBackupSupport(userWallet: UserWallet) {
analyticsEventHandler.send(ButtonSupport(source = AnalyticsParam.ScreensSources.Main))
modelScope.launch {
sendBackupProblemEmailUseCase(userWallet.walletId)
}
}
private fun isMultiWalletTokensLoaded(): Boolean {
return if (stateHolder.value.isRedesignEnabled) {
val selectedWalletUM = stateHolder.getSelectedWalletUM() as? WalletUM.Content ?: return false

View file

@ -1,47 +0,0 @@
package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.card.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO
import javax.inject.Inject
class BackupValidator @Inject constructor() {
fun isValidFull(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO) && validateCurves(cardDTO)
}
fun isValidBackupStatus(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO)
}
private fun validateCurves(cardDTO: CardDTO): Boolean {
val config = CardConfig.createConfig(cardDTO)
// / Since the curve `bls12381_G2_AUG` was added later into first generation of wallets,
// we cannot determine whether this curve is missing due to an error or because the user
// did not want to recreate the wallet.
val expectedCurves = config.mandatoryCurves
.filterNot { it == EllipticCurve.Bls12381G2Aug }
val curves = cardDTO.wallets.map { it.curve }
for (expectedCurve in expectedCurves) {
val cardCurvesCount = curves.count { it == expectedCurve }
// missing curve
if (cardCurvesCount == 0) {
return false
}
// duplicated curve
if (cardCurvesCount > 1) {
return false
}
}
return true
}
private fun validateBackupStatus(cardDTO: CardDTO): Boolean {
val backupStatus = cardDTO.backupStatus
backupStatus ?: return true // for card with null backup status, validation should always returns true
return backupStatus !is CardDTO.BackupStatus.CardLinked
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.hotwallet.CheckHotWalletUpgradeBannerUseCase
@ -56,7 +57,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val notificationsRepository: NotificationsRepository,
private val accountDependencies: AccountDependencies,
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
@ -118,6 +119,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
val isAddFundsBannerShown = isAddFundsBannerVisible(accountStatusList.totalFiatBalance)
buildList {
addBackupErrorNotification(userWallet, clickIntents)
addUsedOutdatedDataNotification(accountStatusList.totalFiatBalance)
addAddFundsBanner(
@ -126,7 +129,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
clickIntents = clickIntents,
)
addCriticalNotifications(userWallet, clickIntents)
addCriticalNotifications(userWallet)
addUpgradeHotWalletPromoNotification(
userWallet = userWallet,
@ -270,20 +273,22 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
)
}
private fun MutableList<WalletNotification>.addCriticalNotifications(
private fun MutableList<WalletNotification>.addBackupErrorNotification(
userWallet: UserWallet,
clickIntents: WalletClickIntents,
) {
addIf(
element = WalletNotification.Critical.BackupError { clickIntents.onBackupErrorClick() },
condition = isWalletBackupProblematicUseCase(userWallet),
)
}
private fun MutableList<WalletNotification>.addCriticalNotifications(userWallet: UserWallet) {
if (userWallet !is UserWallet.Cold) {
return
}
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
addIf(
element = WalletNotification.Critical.BackupError { clickIntents.onBackupErrorClick() },
condition = !backupValidator.isValidBackupStatus(userWallet.scanResponse.card) || userWallet.hasBackupError,
)
addIf(
element = WalletNotification.Critical.DevCard,
condition = !cardTypesResolver.isReleaseFirmwareType(),

View file

@ -8,6 +8,7 @@ import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase
@ -44,7 +45,7 @@ import javax.inject.Inject
internal class GetWalletNotificationsFactory @Inject constructor(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val accountDependencies: AccountDependencies,
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
@ -167,7 +168,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
addIf(
element = WalletNotificationUM.BackupError { clickIntents.onSupportClick() },
condition = !backupValidator.isValidBackupStatus(userWallet.scanResponse.card) || userWallet.hasBackupError,
condition = isWalletBackupProblematicUseCase(userWallet),
)
addIf(

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.wallet.impl.R
@ -55,6 +56,10 @@ internal object WalletAlertUM {
)
}
fun addFundsDisabledForBackupError(onContactSupport: () -> Unit): DialogMessage {
return Dialogs.backupErrorAddFundsDisabled(onContactSupport = onContactSupport)
}
fun insufficientTokensCountForSwapping(): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.action_buttons_swap_no_tokens_added_alert_title),

View file

@ -50,9 +50,9 @@ sealed class WalletNotification(val config: NotificationConfig) {
)
data class BackupError(val onSupportClick: () -> Unit) : Critical(
title = resourceReference(R.string.warning_backup_errors_title),
subtitle = resourceReference(R.string.warning_backup_errors_message),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
title = resourceReference(R.string.warning_backup_error_attention_title),
subtitle = resourceReference(R.string.warning_backup_error_attention_message),
buttonsState = ButtonsState.PrimaryButtonConfig(
text = resourceReference(id = R.string.common_contact_support),
onClick = onSupportClick,
),

View file

@ -17,6 +17,7 @@ import com.tangem.core.ui.extensions.annotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.ForceDarkTheme
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.WalletNotificationTestTags
import com.tangem.feature.wallet.impl.R
@ -66,6 +67,16 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
subtitleColor = TangemTheme.colors.text.secondary,
)
}
is WalletNotification.Critical.BackupError -> {
Notification(
config = item.config,
modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
containerColor = TangemColorPalette.Amaranth_30,
borderColor = TangemColorPalette.Amaranth,
iconTint = TangemTheme.colors.icon.warning,
subtitleColor = TangemTheme.colors.text.primary1,
)
}
is WalletNotification.CreateTangemPayAccount -> {
CreatePaymentAccountNotification(
modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),