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

View file

@ -3,6 +3,7 @@ package com.tangem.tap.di.domain
import android.content.Context import android.content.Context
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.feedback.repository.FeedbackRepository
import dagger.Module import dagger.Module
@ -36,4 +37,16 @@ internal object FeedbackDomainModule {
fun provideSaveBlockchainErrorUseCase(feedbackRepository: FeedbackRepository): SaveBlockchainErrorUseCase { fun provideSaveBlockchainErrorUseCase(feedbackRepository: FeedbackRepository): SaveBlockchainErrorUseCase {
return SaveBlockchainErrorUseCase(feedbackRepository = feedbackRepository) 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.onramp.models)
implementation(projects.domain.offramp) implementation(projects.domain.offramp)
implementation(projects.domain.demo) implementation(projects.domain.demo)
implementation(projects.domain.card)
implementation(projects.domain.feedback)
implementation(deps.lifecycle.compose) implementation(deps.lifecycle.compose)
implementation(deps.compose.foundation) 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.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage 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.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.onramp.model.OnrampSource
@ -23,9 +26,9 @@ import com.tangem.utils.Provider
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList") @Suppress("LongParameterList")
class TokenActionsHandler @AssistedInject constructor( class TokenActionsHandler @AssistedInject constructor(
@ -39,6 +42,8 @@ class TokenActionsHandler @AssistedInject constructor(
@Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit, @Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit,
@Assisted private val coroutineScope: CoroutineScope, @Assisted private val coroutineScope: CoroutineScope,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val messageSender: UiMessageSender, private val messageSender: UiMessageSender,
) { ) {
@ -47,6 +52,8 @@ class TokenActionsHandler @AssistedInject constructor(
} }
fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: CryptoCurrencyData) { fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: CryptoCurrencyData) {
if (isTopUpBlockedByBackupError(action, cryptoCurrencyData.userWallet)) return
onHandleQuickAction( onHandleQuickAction(
HandledQuickAction( HandledQuickAction(
action = action, 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 { private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean {
val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId) val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId)
val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action) 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), 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( data class MinimumAmountError(val amount: String) : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title), title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference( subtitle = resourceReference(

View file

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

View file

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

View file

@ -12,6 +12,7 @@ android {
} }
dependencies { dependencies {
api(projects.domain.account) api(projects.domain.account)
api(projects.domain.card)
api(projects.domain.core) api(projects.domain.core)
api(projects.domain.common) api(projects.domain.common)
api(projects.domain.express) 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.status.utils.CryptoCurrencyMetadataCleaner
import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.express.ExpressServiceFetcher 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.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository 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.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module 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 @Provides
@Singleton @Singleton
fun provideIsAccountsModeEnabledUseCase( 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.card.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.CardDTO
object BackupValidator { class BackupValidator {
fun isValidFull(cardDTO: CardDTO): Boolean { fun isValidFull(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO) && validateCurves(cardDTO) 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.wallets.models)
implementation(projects.domain.visa.models) implementation(projects.domain.visa.models)
implementation(projects.domain.feedback.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() { sealed class Error : AddressValidation() {
data object AddressInWallet : Error() data object AddressInWallet : Error()
data object InvalidAddress : Error() data object InvalidAddress : Error()
data object RecipientWalletBackupError : Error()
data class DataError(val throwable: Throwable) : 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.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase
@ -212,7 +213,6 @@ internal class MultiWalletFinalizeModel @Inject constructor(
backupService.proceedBackup(iconScanRes = iconScanRes) { result -> backupService.proceedBackup(iconScanRes = iconScanRes) { result ->
when (result) { when (result) {
is CompletionResult.Success -> { is CompletionResult.Success -> {
val backupValidator = BackupValidator()
if (backupValidator.isValidBackupStatus(CardDTO(result.data)).not()) { if (backupValidator.isValidBackupStatus(CardDTO(result.data)).not()) {
hasWalletBackupError = true 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.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase 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.backupCardsBatchIds } returns listOf(NON_RING_BATCH_ID, NON_RING_BATCH_ID)
every { backupService.currentState } returns BackupService.State.FinalizingPrimaryCard every { backupService.currentState } returns BackupService.State.FinalizingPrimaryCard
coEvery { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } just Runs coEvery { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } just Runs
every { backupValidator.isValidBackupStatus(any()) } returns true
} }
@Test @Test
@ -427,9 +429,7 @@ internal class MultiWalletFinalizeModelTest {
startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard,
) )
every { backupService.currentState } returns BackupService.State.FinalizingBackupCard(index = 1) every { backupService.currentState } returns BackupService.State.FinalizingBackupCard(index = 1)
every { backupValidator.isValidBackupStatus(any()) } returns true
mockkConstructor(BackupValidator::class)
every { anyConstructed<BackupValidator>().isValidBackupStatus(any()) } returns true
val card: Card = mockk(relaxed = true) val card: Card = mockk(relaxed = true)
val callbackSlot = slot<(CompletionResult<Card>) -> Unit>() val callbackSlot = slot<(CompletionResult<Card>) -> Unit>()
@ -453,8 +453,6 @@ internal class MultiWalletFinalizeModelTest {
Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step) Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step)
Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber) Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber)
Assertions.assertTrue(events.contains(MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded)) Assertions.assertTrue(events.contains(MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded))
unmockkConstructor(BackupValidator::class)
} }
private fun String.lastMaskedExpected(): String { 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 androidx.compose.runtime.Stable
import arrow.core.getOrElse import arrow.core.getOrElse
import arrow.core.left
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM 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.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier 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.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.network.CryptoCurrencyAddress
import com.tangem.domain.models.wallet.UserWallet 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.models.wallet.isLocked
import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
@ -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.model.transformers.SendDestinationValidationStartedTransformer
import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.features.send.impl.R 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.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn import com.tangem.utils.coroutines.saveIn
@ -58,6 +63,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
@Stable @Stable
@ -79,6 +85,9 @@ internal class SendDestinationModel @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
private val sendDestinationAlertFactory: SendDestinationAlertFactory,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
) : Model(), SendDestinationClickIntents { ) : Model(), SendDestinationClickIntents {
private val params: SendDestinationComponentParams = paramsContainer.require() private val params: SendDestinationComponentParams = paramsContainer.require()
@ -95,6 +104,8 @@ internal class SendDestinationModel @Inject constructor(
private val validationJobHolder = JobHolder() private val validationJobHolder = JobHolder()
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
init { init {
configDestinationNavigation() configDestinationNavigation()
subscribeOnQRScannerResult() 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) { private fun validate(address: String, memo: String?, type: EnterAddressSource? = null) {
modelScope.launch { modelScope.launch {
_uiState.update(SendDestinationValidationStartedTransformer) _uiState.update(SendDestinationValidationStartedTransformer)
val addressValidationResult = validateWalletAddressUseCase( var addressValidationResult = validateWalletAddressUseCase(
userWalletId = userWalletId, userWalletId = userWalletId,
network = cryptoCurrency.network, network = cryptoCurrency.network,
address = address, address = address,
senderAddresses = senderAddresses.value, senderAddresses = senderAddresses.value,
allowSelfSend = params.isAllowSelfSend, 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( val memoValidationResult = validateWalletMemoUseCase(
userWalletId = userWalletId, userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,

View file

@ -24,14 +24,7 @@ internal class SendDestinationValidationResultTransformer(
val isValidAddress = addressValidationResult.isRight() val isValidAddress = addressValidationResult.isRight()
val isValidMemo = shouldDisableMemo || memoValidationResult.isRight() val isValidMemo = shouldDisableMemo || memoValidationResult.isRight()
val addressErrorText = addressValidationResult.mapLeft { error -> val addressErrorText = resolveAddressErrorText()
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 blockchainAddress = val blockchainAddress =
(addressValidationResult.getOrNull() as? AddressValidation.Success.ValidNamedAddress)?.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( private fun buildMemoField(
memoField: DestinationTextFieldUM.RecipientMemo?, memoField: DestinationTextFieldUM.RecipientMemo?,
isValidMemo: Boolean, 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.decompose.model.ParamsContainer
import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format 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.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.domain.transaction.usecase.IsMemoRequiredUseCase
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact 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.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -38,6 +42,8 @@ internal class SwapNotificationsModel @Inject constructor(
private val swapNotificationsUpdateTrigger: DefaultSwapNotificationsUpdateTrigger, private val swapNotificationsUpdateTrigger: DefaultSwapNotificationsUpdateTrigger,
private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger, private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger,
private val isMemoRequiredUseCase: IsMemoRequiredUseCase, private val isMemoRequiredUseCase: IsMemoRequiredUseCase,
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
) : Model() { ) : Model() {
@ -47,6 +53,8 @@ internal class SwapNotificationsModel @Inject constructor(
private var notificationData = params.swapNotificationData private var notificationData = params.swapNotificationData
private var lastSentErrorKeys: Set<Pair<String, Map<String, String>>> = emptySet() private var lastSentErrorKeys: Set<Pair<String, Map<String, String>>> = emptySet()
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
val uiState: StateFlow<ImmutableList<NotificationUM>> val uiState: StateFlow<ImmutableList<NotificationUM>>
field = MutableStateFlow<ImmutableList<NotificationUM>>(persistentListOf()) field = MutableStateFlow<ImmutableList<NotificationUM>>(persistentListOf())
@ -73,6 +81,7 @@ internal class SwapNotificationsModel @Inject constructor(
addInsufficientFundsNotification() addInsufficientFundsNotification()
addExpressErrorNotification() addExpressErrorNotification()
addDestinationTagRequiredNotification() addDestinationTagRequiredNotification()
addDestinationBackupErrorNotification()
maybeAddPriceImpactNotification() 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() { private fun MutableList<NotificationUM>.addInsufficientFundsNotification() {
val enteredFromAmount = notificationData.enteredFromAmount ?: return val enteredFromAmount = notificationData.enteredFromAmount ?: return
val balance = notificationData.fromCryptoCurrencyStatus?.value?.amount ?: return val balance = notificationData.fromCryptoCurrencyStatus?.value?.amount ?: return

View file

@ -56,6 +56,7 @@ dependencies {
implementation(projects.domain.express.models) implementation(projects.domain.express.models)
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.account.status) implementation(projects.domain.account.status)
implementation(projects.domain.card)
implementation(projects.domain.visa) implementation(projects.domain.visa)
implementation(projects.domain.markets) implementation(projects.domain.markets)
implementation(projects.domain.swap) 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.format.bigdecimal.format
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction 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.parseBigDecimal
import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.core.ui.utils.parseToBigDecimal 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.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase 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.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -49,6 +51,7 @@ import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.express.models.ProviderFilterType import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.FeedbackEmailType
@ -149,6 +152,8 @@ internal class SwapModel @Inject constructor(
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val swapInteractor: SwapInteractor, private val swapInteractor: SwapInteractor,
private val swapTransferInteractor: SwapTransferInteractor, private val swapTransferInteractor: SwapTransferInteractor,
private val swapTransferStateBuilder: SwapTransferStateBuilder, private val swapTransferStateBuilder: SwapTransferStateBuilder,
@ -472,6 +477,12 @@ internal class SwapModel @Inject constructor(
val selectedCurrencyStatus = result.currency val selectedCurrencyStatus = result.currency
val selectedAccount = result.account.account val selectedAccount = result.account.account
if (!isFromDirection && isWalletBackupProblematicUseCase(selectedUserWallet)) {
router.pop()
showBackupErrorAlert(selectedUserWallet.walletId)
return
}
val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = if (isFromDirection) { val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = if (isFromDirection) {
SwapCurrencyStatus( SwapCurrencyStatus(
userWallet = selectedUserWallet, 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( private fun showTransactionErrorAlert(
error: SwapTransactionState.Error, error: SwapTransactionState.Error,
onSupportClick: (String) -> Unit = ::onFailedTxEmailClick, 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.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -101,6 +103,8 @@ internal abstract class SwapModelTestBase {
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true) protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true) protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
protected val calculateAmountUseCase: CalculateAmountUseCase = 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 chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getUserCountryUseCase: GetUserCountryUseCase = mockk(relaxed = true) private val getUserCountryUseCase: GetUserCountryUseCase = mockk(relaxed = true)
@ -171,6 +175,8 @@ internal abstract class SwapModelTestBase {
getSwapUiModeUseCase = getSwapUiModeUseCase, getSwapUiModeUseCase = getSwapUiModeUseCase,
setSwapUiModeUseCase = setSwapUiModeUseCase, setSwapUiModeUseCase = setSwapUiModeUseCase,
calculateAmountUseCase = calculateAmountUseCase, calculateAmountUseCase = calculateAmountUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
) )
// region builders // region builders

View file

@ -68,6 +68,7 @@ dependencies {
implementation(projects.domain.demo) implementation(projects.domain.demo)
implementation(projects.domain.dynamicAddresses) implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.feedback)
implementation(projects.domain.markets.models) implementation(projects.domain.markets.models)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.notifications.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.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction 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.domain.models.currency.CryptoCurrency
import com.tangem.features.tokendetails.impl.R import com.tangem.features.tokendetails.impl.R
import javax.inject.Inject import javax.inject.Inject
@ -70,4 +71,8 @@ internal class TokenDetailsDialogFactory @Inject constructor(
fun showError(text: TextReference) { fun showError(text: TextReference) {
uiMessageSender.send(DialogMessage(message = text)) 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.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.models.StatusSource import com.tangem.domain.models.StatusSource
@ -159,6 +161,8 @@ internal class TokenDetailsModel @Inject constructor(
private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase,
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val associateAssetUseCase: AssociateAssetUseCase, private val associateAssetUseCase: AssociateAssetUseCase,
private val retryIncompleteTransactionUseCase: RetryIncompleteTransactionUseCase, private val retryIncompleteTransactionUseCase: RetryIncompleteTransactionUseCase,
private val openTrustlineUseCase: OpenTrustlineUseCase, private val openTrustlineUseCase: OpenTrustlineUseCase,
@ -588,6 +592,7 @@ internal class TokenDetailsModel @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) { if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
return return
} }
if (isTopUpBlockedByBackupError()) return
val status = cryptoCurrencyStatus ?: return val status = cryptoCurrencyStatus ?: return
modelScope.launch { modelScope.launch {
@ -710,6 +715,7 @@ internal class TokenDetailsModel @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) { if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
return return
} }
if (isTopUpBlockedByBackupError()) return
modelScope.launch { modelScope.launch {
if (needShowYieldSupplyWarning()) { if (needShowYieldSupplyWarning()) {
@ -824,6 +830,7 @@ internal class TokenDetailsModel @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) { if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
return return
} }
if (isTopUpBlockedByBackupError()) return
modelScope.launch { modelScope.launch {
if (checkYieldSupply && needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)) { if (checkYieldSupply && needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)) {
@ -1199,6 +1206,19 @@ internal class TokenDetailsModel @Inject constructor(
return true 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() { private fun openStaking() {
modelScope.launch { modelScope.launch {
getStakingAvailabilityUseCase.invokeSync(userWalletId, cryptoCurrency) 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.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam 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.MainScreenAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped 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.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap 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.lce.Lce
import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceError
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountId 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.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase 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.impl.R
import com.tangem.feature.wallet.presentation.wallet.domain.unwrap import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
@ -144,6 +148,9 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private val isCryptoCurrencyCouldHideUseCase: IsCryptoCurrencyCouldHideUseCase, private val isCryptoCurrencyCouldHideUseCase: IsCryptoCurrencyCouldHideUseCase,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val uiMessageSender: UiMessageSender, private val uiMessageSender: UiMessageSender,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents {
override fun onSendClick( override fun onSendClick(
@ -196,6 +203,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
event?.let { analyticsEventHandler.send(it) } event?.let { analyticsEventHandler.send(it) }
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
modelScope.launch(dispatchers.main) { modelScope.launch(dispatchers.main) {
if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) { if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) {
stateHolder.hideBottomSheet() stateHolder.hideBottomSheet()
@ -339,6 +348,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
) )
if (handleUnavailabilityReason(unavailabilityReason)) return if (handleUnavailabilityReason(unavailabilityReason)) return
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
appRouter.push( appRouter.push(
AppRoute.Onramp( AppRoute.Onramp(
@ -363,6 +373,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
) )
if (handleUnavailabilityReason(unavailabilityReason)) return if (handleUnavailabilityReason(unavailabilityReason)) return
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
modelScope.launch(dispatchers.main) { modelScope.launch(dispatchers.main) {
if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) { if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) {
@ -451,6 +462,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
} }
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) { override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
if (isTopUpBlockedByBackupError(userWalletId)) return
if (!isMultiWalletTokensLoaded()) return if (!isMultiWalletTokensLoaded()) return
modelScope.launch { modelScope.launch {
@ -469,6 +481,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
} }
override fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) { override fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) {
if (isTopUpBlockedByBackupError(userWalletId)) return
onMultiWalletActionClick( onMultiWalletActionClick(
statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId), statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId),
route = AppRoute.BuyCrypto(userWalletId = userWalletId), route = AppRoute.BuyCrypto(userWalletId = userWalletId),
@ -565,6 +579,26 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
return true 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 { private fun isMultiWalletTokensLoaded(): Boolean {
return if (stateHolder.value.isRedesignEnabled) { return if (stateHolder.value.isRedesignEnabled) {
val selectedWalletUM = stateHolder.getSelectedWalletUM() as? WalletUM.Content ?: return false 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.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.hotwallet.CheckHotWalletUpgradeBannerUseCase import com.tangem.domain.hotwallet.CheckHotWalletUpgradeBannerUseCase
@ -56,7 +57,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase, private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator, private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val notificationsRepository: NotificationsRepository, private val notificationsRepository: NotificationsRepository,
private val accountDependencies: AccountDependencies, private val accountDependencies: AccountDependencies,
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase, private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
@ -118,6 +119,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
val isAddFundsBannerShown = isAddFundsBannerVisible(accountStatusList.totalFiatBalance) val isAddFundsBannerShown = isAddFundsBannerVisible(accountStatusList.totalFiatBalance)
buildList { buildList {
addBackupErrorNotification(userWallet, clickIntents)
addUsedOutdatedDataNotification(accountStatusList.totalFiatBalance) addUsedOutdatedDataNotification(accountStatusList.totalFiatBalance)
addAddFundsBanner( addAddFundsBanner(
@ -126,7 +129,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
clickIntents = clickIntents, clickIntents = clickIntents,
) )
addCriticalNotifications(userWallet, clickIntents) addCriticalNotifications(userWallet)
addUpgradeHotWalletPromoNotification( addUpgradeHotWalletPromoNotification(
userWallet = userWallet, userWallet = userWallet,
@ -270,20 +273,22 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
) )
} }
private fun MutableList<WalletNotification>.addCriticalNotifications( private fun MutableList<WalletNotification>.addBackupErrorNotification(
userWallet: UserWallet, userWallet: UserWallet,
clickIntents: WalletClickIntents, clickIntents: WalletClickIntents,
) { ) {
addIf(
element = WalletNotification.Critical.BackupError { clickIntents.onBackupErrorClick() },
condition = isWalletBackupProblematicUseCase(userWallet),
)
}
private fun MutableList<WalletNotification>.addCriticalNotifications(userWallet: UserWallet) {
if (userWallet !is UserWallet.Cold) { if (userWallet !is UserWallet.Cold) {
return return
} }
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
addIf(
element = WalletNotification.Critical.BackupError { clickIntents.onBackupErrorClick() },
condition = !backupValidator.isValidBackupStatus(userWallet.scanResponse.card) || userWallet.hasBackupError,
)
addIf( addIf(
element = WalletNotification.Critical.DevCard, element = WalletNotification.Critical.DevCard,
condition = !cardTypesResolver.isReleaseFirmwareType(), 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.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase
@ -44,7 +45,7 @@ import javax.inject.Inject
internal class GetWalletNotificationsFactory @Inject constructor( internal class GetWalletNotificationsFactory @Inject constructor(
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase, private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator, private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val accountDependencies: AccountDependencies, private val accountDependencies: AccountDependencies,
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase, private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase, private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
@ -167,7 +168,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
addIf( addIf(
element = WalletNotificationUM.BackupError { clickIntents.onSupportClick() }, element = WalletNotificationUM.BackupError { clickIntents.onSupportClick() },
condition = !backupValidator.isValidBackupStatus(userWallet.scanResponse.card) || userWallet.hasBackupError, condition = isWalletBackupProblematicUseCase(userWallet),
) )
addIf( 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.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction 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.domain.models.currency.CryptoCurrency
import com.tangem.feature.wallet.impl.R 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 { fun insufficientTokensCountForSwapping(): DialogMessage {
return DialogMessage( return DialogMessage(
title = resourceReference(R.string.action_buttons_swap_no_tokens_added_alert_title), 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( data class BackupError(val onSupportClick: () -> Unit) : Critical(
title = resourceReference(R.string.warning_backup_errors_title), title = resourceReference(R.string.warning_backup_error_attention_title),
subtitle = resourceReference(R.string.warning_backup_errors_message), subtitle = resourceReference(R.string.warning_backup_error_attention_message),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( buttonsState = ButtonsState.PrimaryButtonConfig(
text = resourceReference(id = R.string.common_contact_support), text = resourceReference(id = R.string.common_contact_support),
onClick = onSupportClick, 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.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.ForceDarkTheme 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.res.TangemTheme
import com.tangem.core.ui.test.WalletNotificationTestTags import com.tangem.core.ui.test.WalletNotificationTestTags
import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.impl.R
@ -66,6 +67,16 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
subtitleColor = TangemTheme.colors.text.secondary, 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 -> { is WalletNotification.CreateTangemPayAccount -> {
CreatePaymentAccountNotification( CreatePaymentAccountNotification(
modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),