Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-27 16:13:43 +03:00
parent 2c10b8816a
commit 2990b16ade
9 changed files with 166 additions and 82 deletions

View file

@ -2,9 +2,11 @@ package com.tangem.tap.domain.userWalletList.repository
import arrow.core.Either
import arrow.core.left
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.right
import com.tangem.common.*
import com.tangem.common.core.TangemSdkError
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
@ -237,7 +239,7 @@ internal class DefaultUserWalletsListRepository(
}
UserWalletsListRepository.UnlockMethod.AccessCode -> {
if (userWallet !is UserWallet.Hot) {
raise(UnlockWalletError.UnableToUnlock)
raise(UnlockWalletError.UnableToUnlock.Empty)
}
val encryptionKey = requestPasswordRecursive(
@ -245,8 +247,8 @@ internal class DefaultUserWalletsListRepository(
block = { password ->
runSuspendCatching {
userWalletEncryptionKeysRepository.getEncryptedWithPassword(userWalletId, password)
}.onFailure {
raise(UnlockWalletError.UnableToUnlock)
}.onFailure { error ->
raise(UnlockWalletError.UnableToUnlock.RawException(error))
}.getOrNull()
},
biometryFallback = {
@ -262,13 +264,11 @@ internal class DefaultUserWalletsListRepository(
sensitiveInformationRepository.getAll(listOf(encryptionKey))
.doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } }
.doOnFailure { error ->
raise(UnlockWalletError.UnableToUnlock)
}
.doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) }
}
is UserWalletsListRepository.UnlockMethod.Scan -> {
if (userWallet !is UserWallet.Cold) {
raise(UnlockWalletError.UnableToUnlock)
raise(UnlockWalletError.UnableToUnlock.Empty)
}
val scanResponse = unlockMethod.scanResponse ?: run {
@ -287,12 +287,12 @@ internal class DefaultUserWalletsListRepository(
val encryptionKey = UserWalletEncryptionKey(
walletId = userWallet.walletId,
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock),
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty),
)
sensitiveInformationRepository.getAll(listOf(encryptionKey))
.doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } }
.doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) }
.doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) }
}
}
}
@ -306,9 +306,8 @@ internal class DefaultUserWalletsListRepository(
val biometricKeys = runSuspendCatching {
userWalletEncryptionKeysRepository.getAllBiometric()
}.getOrElse {
// TODO handle error properly [REDACTED_TASK_KEY]
raise(UnlockWalletError.UserCancelled)
}.getOrElse { exception ->
catchBiometricException(exception)
}
val unsecuredKeys = userWalletEncryptionKeysRepository.getAllUnsecured()
@ -327,14 +326,14 @@ internal class DefaultUserWalletsListRepository(
// if we cant unlock any of the locked wallets, return error
// (isLocked remains `true` here because we haven't updated the wallets yet)
if (unlockedWallets.any { it.isLocked }.not()) {
raise(UnlockWalletError.UnableToUnlock)
raise(UnlockWalletError.UnableToUnlock.Empty)
}
sensitiveInformationRepository.getAll(allKeys)
.doOnSuccess { sensitiveInfo ->
updateWallets { wallets -> wallets?.updateWith(sensitiveInfo) }
}
.doOnFailure { raise(UnlockWalletError.UnableToUnlock) }
.doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) }
}
override suspend fun lockAllWallets(): Either<LockWalletsError, Unit> = either {
@ -460,6 +459,27 @@ internal class DefaultUserWalletsListRepository(
}
}
private fun Raise<UnlockWalletError>.catchBiometricException(ex: Throwable): Nothing {
val reason = when (ex) {
is TangemSdkError.AuthenticationLockout ->
UnlockWalletError.UnableToUnlock.Reason.BiometricsAuthenticationLockout(isPermanent = false)
is TangemSdkError.AuthenticationPermanentLockout ->
UnlockWalletError.UnableToUnlock.Reason.BiometricsAuthenticationLockout(isPermanent = true)
is TangemSdkError.KeystoreInvalidated ->
UnlockWalletError.UnableToUnlock.Reason.AllKeysInvalidated
is TangemSdkError.AuthenticationUnavailable ->
UnlockWalletError.UnableToUnlock.Reason.BiometricsAuthenticationDisabled
is TangemSdkError.AuthenticationCanceled,
is TangemSdkError.AuthenticationAlreadyInProgress,
-> raise(UnlockWalletError.UserCancelled)
else -> raise(UnlockWalletError.UnableToUnlock.RawException(ex))
}
raise(UnlockWalletError.UnableToUnlock.WithReason(reason))
}
/**
* Find the nearest available wallet that can be selected
*

View file

@ -41,6 +41,7 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.promo.models)
implementation(projects.domain.common)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain) {

View file

@ -0,0 +1,78 @@
package com.tangem.common.ui.userwallet
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.common.wallets.error.UnlockWalletError
import com.tangem.domain.common.wallets.error.UnlockWalletError.UnableToUnlock.Reason
inline fun UnlockWalletError.handle(
onAlreadyUnlocked: () -> Unit = {},
onUserCancelled: () -> Unit = {},
noinline showMessage: (EventMessage) -> Unit,
) {
when (this) {
UnlockWalletError.AlreadyUnlocked -> onAlreadyUnlocked()
UnlockWalletError.ScannedCardWalletNotMatched -> {
showMessage(
DialogMessage(
title = resourceReference(R.string.common_warning),
message = resourceReference(R.string.error_wrong_wallet_tapped),
),
)
}
UnlockWalletError.UserCancelled -> onUserCancelled()
UnlockWalletError.UserWalletNotFound -> {
// This should never happen in this flow, as we always check for the wallet existence before unlocking
showMessage(SnackbarMessage(TextReference.Res(R.string.generic_error)))
}
is UnlockWalletError.UnableToUnlock -> handleUnableToUnlock(this, showMessage)
}
}
fun handleUnableToUnlock(error: UnlockWalletError.UnableToUnlock, showDialog: (DialogMessage) -> Unit) {
val dialogMessage = when (error) {
is UnlockWalletError.UnableToUnlock.WithReason -> {
when (error.reason) {
Reason.AllKeysInvalidated -> {
DialogMessage(
title = resourceReference(R.string.biometric_updated_warning_title),
message = resourceReference(R.string.biometric_updated_warning_description),
)
}
Reason.BiometricsAuthenticationDisabled -> {
DialogMessage(
title = resourceReference(R.string.biometric_disabled_warning_title),
message = resourceReference(R.string.biometric_disabled_warning_description),
)
}
is Reason.BiometricsAuthenticationLockout -> {
if ((error.reason as Reason.BiometricsAuthenticationLockout).isPermanent) {
DialogMessage(
title = resourceReference(R.string.biometric_lockout_permanent_warning_title),
message = resourceReference(R.string.biometric_lockout_permanent_warning_description_2),
)
} else {
DialogMessage(
title = resourceReference(R.string.biometric_lockout_warning_title),
message = resourceReference(R.string.biometric_lockout_warning_description_2),
)
}
}
}
}
is UnlockWalletError.UnableToUnlock.RawException -> {
DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
message = stringReference(error.throwable.toString()),
)
}
UnlockWalletError.UnableToUnlock.Empty -> return
}
showDialog(dialogMessage)
}

View file

@ -1420,9 +1420,14 @@
<string name="tangempay_onboarding_security_description">あなたの住所や資産を開示することなく、個別の支払い用アカウントが作成されます。</string>
<string name="tangempay_onboarding_security_title">他に類を見ないプライバシー</string>
<string name="tangempay_onboarding_title">無料のTangem Payカードを数分でゲットしましょう</string>
<string name="tangempay_payment_account">支払いアカウント</string>
<string name="tangempay_payment_account_sync_needed">支払いアカウントの同期が必要です</string>
<string name="tangempay_service_unavailable_description">技術的な問題を修正しています。後でもう一度お試しください。</string>
<string name="tangempay_service_unavailable_title">サービスは一時的に利用できません</string>
<string name="tangempay_service_unreachable_try_later">現在サービスに接続できません。後ほどもう一度お試しください。</string>
<string name="tangempay_temporarily_unavailable">Tangem Payは一時的に利用できません</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">カードまたはリングを使用して、支払いアカウントへのアクセスを復元してください。</string>
<string name="this_is_my_wallet_title">これは私のウォレットです</string>
<string name="toast_balances_hidden">残高非表示</string>
<string name="toast_balances_shown">残高表示</string>

View file

@ -139,10 +139,17 @@
<string name="balance_hidden_title">Balances are hidden</string>
<string name="beta_mode_warning_message">According to the blockchain developers, Kaspa tokens are currently in beta. Stay tuned for updates!</string>
<string name="beta_mode_warning_title">Beta Mode</string>
<string name="biometric_disabled_warning_description">Biometrics are turned off on your device, so you cant use them to unlock your wallets. Enable biometrics in your device settings to use this method again.</string>
<string name="biometric_disabled_warning_title">Biometric authentication disabled</string>
<string name="biometric_lockout_permanent_warning_description">Please scan the card or ring</string>
<string name="biometric_lockout_permanent_warning_description_2">Youve reached the limit of biometric attempts. Please unlock your wallet with a device tap or enter your access code.</string>
<string name="biometric_lockout_permanent_warning_title">Biometric authentication locked</string>
<string name="biometric_lockout_warning_description">Please try again in 30 seconds or scan the card or ring</string>
<string name="biometric_lockout_warning_description_2">Biometric login is temporarily locked. Please try again in 30 seconds, or unlock your wallet with a device tap or access code.</string>
<string name="biometric_lockout_warning_title">Too many attempts</string>
<string name="biometric_unavailable_warning">You have disabled biometric authentication on your phone and will not be able to save wallets in the app. To save wallets, please enable the biometric authentication function in your phone settings.</string>
<string name="biometric_updated_warning_description">Biometrics on your device have been updated. Please select your wallet and enter its access code to enable biometric login again.</string>
<string name="biometric_updated_warning_title">Attention required</string>
<string name="bitcoin_promo_activation_error">An error occurred while processing your promo code. Please try again later.</string>
<string name="bitcoin_promo_activation_error_title">Activation error</string>
<string name="bitcoin_promo_activation_success">Your promo code was successfully activated. The reward will be credited to your Bitcoin account within 14 days.</string>
@ -835,6 +842,8 @@
<string name="no_account_polkadot">Destination account is not active. Send %s or more to activate the account.</string>
<string name="no_account_send_to_create">To create account send funds to this address</string>
<string name="no_trustline_xlm_asset">The destination account does not have a trustline for the asset being sent.</string>
<string name="notification_black_friday_text">Plus Rewards in BTC per Set.\nHurry up</string>
<string name="notification_black_friday_title">Black Friday: up to 25% OFF</string>
<string name="notification_referral_promo_button">Join Now</string>
<string name="notification_referral_promo_text">Share your code - earn 5 USDT per sale. Your friend gets 10% OFF.</string>
<string name="notification_referral_promo_title">Get REWARDS for every friend!</string>

View file

@ -6,7 +6,20 @@ sealed interface UnlockWalletError {
data object UserWalletNotFound : UnlockWalletError
data object UnableToUnlock : UnlockWalletError
sealed class UnableToUnlock : UnlockWalletError {
data object Empty : UnableToUnlock()
data class RawException(val throwable: Throwable) : UnableToUnlock()
data class WithReason(val reason: Reason) : UnableToUnlock()
sealed class Reason {
data class BiometricsAuthenticationLockout(val isPermanent: Boolean) : Reason()
data object AllKeysInvalidated : Reason()
data object BiometricsAuthenticationDisabled : Reason()
}
}
data object UserCancelled : UnlockWalletError

View file

@ -1,6 +1,7 @@
package com.tangem.features.details.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.userwallet.handle
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -98,11 +99,14 @@ internal class UserWalletListModel @Inject constructor(
if (hotWalletFeatureToggles.isHotWalletEnabled) {
modelScope.launch {
unlockWalletUseCase(userWalletId)
.onRight {
router.push(AppRoute.WalletSettings(userWalletId))
}
.onRight { router.push(AppRoute.WalletSettings(userWalletId)) }
.onLeft { error ->
Timber.e("Failed to unlock wallet $userWalletId: $error")
error.handle(
onUserCancelled = {},
onAlreadyUnlocked = { router.push(AppRoute.WalletSettings(userWalletId)) },
showMessage = messageSender::send,
)
}
}
} else {

View file

@ -5,20 +5,17 @@ import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.routing.AppRoute.*
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.notifications.NotificationId
import com.tangem.common.ui.userwallet.handle
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.domain.card.SetCardWasScannedUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.UnlockWalletError
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
@ -202,32 +199,12 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
.onLeft {
val selectedUserWalletId = stateHolder.getSelectedWalletId()
nonBiometricUnlockWalletUseCase(selectedUserWalletId)
.onLeft {
when (it) {
UnlockWalletError.AlreadyUnlocked -> Unit
UnlockWalletError.ScannedCardWalletNotMatched -> {
uiMessageSender.send(
message = DialogMessage(
title = resourceReference(R.string.common_warning),
message = resourceReference(R.string.error_wrong_wallet_tapped),
),
)
}
UnlockWalletError.UnableToUnlock -> {
Timber.e("Unable to unlock wallet with id: $selectedUserWalletId")
uiMessageSender.send(
SnackbarMessage(TextReference.Res(R.string.generic_error)),
)
}
UnlockWalletError.UserCancelled -> Unit
UnlockWalletError.UserWalletNotFound -> {
// This should never happen in this flow
Timber.e("User wallet not found for unlock: $selectedUserWalletId")
uiMessageSender.send(
SnackbarMessage(TextReference.Res(R.string.generic_error)),
)
}
}
.onLeft { error ->
error.handle(
onAlreadyUnlocked = {},
onUserCancelled = {},
showMessage = uiMessageSender::send,
)
}
}
}

View file

@ -1,15 +1,12 @@
package com.tangem.features.welcome.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.userwallet.handle
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.UnlockWalletError
import com.tangem.domain.models.wallet.UserWallet
@ -19,9 +16,7 @@ import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.NonBiometricUnlockWalletUseCase
import com.tangem.features.wallet.utils.UserWalletsFetcher
import com.tangem.features.welcome.impl.R
import com.tangem.features.welcome.impl.ui.state.WelcomeUM
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -29,7 +24,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ -150,10 +144,10 @@ internal class WelcomeModel @Inject constructor(
}
private suspend fun onlyOneHotWalletWithAccessCode(): Boolean {
val userWalletsWithLock = userWalletsListRepository.userWalletsSync()
if (userWalletsWithLock.size != 1) return false
val wallet = userWalletsWithLock.first()
return wallet is UserWallet.Hot && wallet.hotWalletId.authType != HotWalletId.AuthType.NoPassword
val userWallets = userWalletsListRepository.userWalletsSync()
if (userWallets.size != 1) return false
val wallet = userWallets.first()
return wallet is UserWallet.Hot && wallet.isLocked
}
private fun onUserWalletClick(userWallet: UserWallet) = modelScope.launch {
@ -189,32 +183,15 @@ internal class WelcomeModel @Inject constructor(
}
suspend fun UnlockWalletError.handle(specificWalletId: UserWalletId?, onUserCancelled: suspend () -> Unit = { }) {
when (this) {
UnlockWalletError.AlreadyUnlocked -> {
handle(
onAlreadyUnlocked = {
// this should not happen, as we check for locked state before this
specificWalletId?.let { userWalletsListRepository.select(it) }
router.replaceAll(AppRoute.Wallet)
}
UnlockWalletError.ScannedCardWalletNotMatched -> {
uiMessageSender.send(
message = DialogMessage(
title = resourceReference(R.string.common_warning),
message = resourceReference(R.string.error_wrong_wallet_tapped),
),
)
}
UnlockWalletError.UnableToUnlock -> {
// TODO Unable to unlock the wallet"
}
UnlockWalletError.UserCancelled -> onUserCancelled()
UnlockWalletError.UserWalletNotFound -> {
// This should never happen in this flow, as we always check for the wallet existence before unlocking
Timber.e("User wallet not found for unlock: $specificWalletId")
uiMessageSender.send(
SnackbarMessage(TextReference.Res(R.string.generic_error)),
)
}
}
},
onUserCancelled = { onUserCancelled() },
showMessage = uiMessageSender::send,
)
}
private fun updateSelectState(block: (WelcomeUM.SelectWallet) -> WelcomeUM.SelectWallet) {