Updated on 2026-08-14

This commit is contained in:
Tangem 2023-03-07 13:17:54 +03:00
commit 3b63151a79
28 changed files with 415 additions and 205 deletions

View file

@ -117,6 +117,8 @@ object TangemSdkErrorMapper {
is TangemSdkError.BiometricsAuthenticationLockout -> error
is TangemSdkError.BiometricsAuthenticationPermanentLockout -> error
is TangemSdkError.UserCanceledBiometricsAuthentication -> error
is TangemSdkError.EncryptionOperationFailed -> error
is TangemSdkError.InvalidEncryptionKey -> error
}
}
}

View file

@ -1,16 +0,0 @@
package com.tangem.tap.domain.userWalletList
import com.tangem.common.core.TangemError
import com.tangem.wallet.R
sealed class UserWalletListError(code: Int) : TangemError(code) {
override val silent: Boolean
get() = (cause as? TangemError)?.silent == true
override val messageResId: Int? = null
object WalletAlreadySaved : UserWalletListError(code = 60001) {
override var customMessage: String = "This wallet has already been saved, you can add another one"
override val messageResId: Int = R.string.user_wallet_list_error_wallet_already_saved
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.tap.domain.userWalletList
import com.tangem.common.core.TangemError
import com.tangem.wallet.R
sealed class UserWalletsListError(code: Int) : TangemError(code) {
override val silent: Boolean
get() = (cause as? TangemError)?.silent == true
override val messageResId: Int? = null
object WalletAlreadySaved : UserWalletsListError(code = 60001) {
override var customMessage: String = "This wallet has already been saved, you can add another one"
override val messageResId: Int = R.string.user_wallet_list_error_wallet_already_saved
}
object InvalidEncryptionKey : UserWalletsListError(code = 60002) {
override var customMessage: String = "Invalid encryption key"
}
data class BiometricsAuthenticationLockout(val isPermanent: Boolean) : UserWalletsListError(code = 60003) {
override var customMessage: String = "Biometric authentication lockout, permanent: $isPermanent"
}
data class UnableToUnlockUserWallets(override val cause: Throwable? = null) : UserWalletsListError(code = 60004) {
override var customMessage: String = "An error has occurred, please scan your card to log in"
override val messageResId: Int = R.string.user_wallet_list_error_unable_to_unlock
}
}

View file

@ -39,7 +39,7 @@ interface UserWalletsListManager {
/**
* Save provided user wallet and set it as selected
* @param userWallet [UserWallet] to save
* @param canOverride If false, then terminate with [UserWalletListError.WalletAlreadySaved] when user tries
* @param canOverride If false, then terminate with [UserWalletsListError.WalletAlreadySaved] when user tries
* to save an already saved card
* @return [CompletionResult] of operation
*/
@ -47,7 +47,7 @@ interface UserWalletsListManager {
/**
* Same as [save] but not change selected user wallet ID
* and not terminate with [UserWalletListError.WalletAlreadySaved] if [UserWallet] already saved
* and not terminate with [UserWalletsListError.WalletAlreadySaved] if [UserWallet] already saved
*
* Can terminate with [NoSuchElementException] if unable to find [UserWallet] with provided [UserWalletId]
* @param userWalletId update [UserWallet] with that [UserWalletId]
@ -108,7 +108,7 @@ interface UserWalletsListManager {
* @return [CompletionResult] of operation, with selected [UserWallet]
* or null if there is no selected [UserWallet]
* */
suspend fun unlock(): CompletionResult<UserWallet?>
suspend fun unlock(): CompletionResult<UserWallet>
/**
* Remove [UserWallet]s from [userWallets] and set [isLocked] as true

View file

@ -16,6 +16,8 @@ val UserWalletsListManager.isLockable: Boolean
*
* @return If [UserWalletsListManager] not implements [UserWalletsListManager.Lockable] returns [Flow] which
* produces only one false value
*
* @see UserWalletsListManager.Lockable.isLockedSync
* */
val UserWalletsListManager.isLocked: Flow<Boolean>
get() = asLockable()?.isLocked ?: flowOf(false)
@ -24,6 +26,8 @@ val UserWalletsListManager.isLocked: Flow<Boolean>
* Indicates that the [UserWalletsListManager] is locked
*
* @return If [UserWalletsListManager] not implements [UserWalletsListManager.Lockable] returns false
*
* @see UserWalletsListManager.Lockable.isLockedSync
* */
val UserWalletsListManager.isLockedSync: Boolean
get() = asLockable()?.isLockedSync ?: false
@ -32,15 +36,22 @@ val UserWalletsListManager.isLockedSync: Boolean
* Call [UserWalletsListManager.Lockable.unlock] if [UserWalletsListManager] implements [UserWalletsListManager.Lockable]
*
* @return If [UserWalletsListManager] not implements [UserWalletsListManager.Lockable]
* returns [CompletionResult.Success] with [UserWalletsListManager.selectedUserWalletSync]
* returns [CompletionResult.Failure] with [UserWalletsListError.UnableToUnlockUserWallets]
*
* If [UserWalletsListManager] implements [UserWalletsListManager.Lockable]
* returns [CompletionResult.Success] with selected [UserWallet]
*
* @see UserWalletsListManager.Lockable.unlock
* */
suspend fun UserWalletsListManager.unlockIfLockable(): CompletionResult<UserWallet?> {
return asLockable()?.unlock() ?: CompletionResult.Success(selectedUserWalletSync)
suspend fun UserWalletsListManager.unlockIfLockable(): CompletionResult<UserWallet> {
return asLockable()?.unlock() ?: CompletionResult.Failure(UserWalletsListError.UnableToUnlockUserWallets())
}
/**
* Call [UserWalletsListManager.Lockable.lock] if [UserWalletsListManager] implements [UserWalletsListManager.Lockable]
* or do nothing otherwise
*
* @see UserWalletsListManager.Lockable.lock
* */
fun UserWalletsListManager.lockIfLockable() {
asLockable()?.lock()

View file

@ -1,9 +1,10 @@
package com.tangem.tap.domain.userWalletList.implementation
import com.tangem.common.*
import com.tangem.common.extensions.guard
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.userWalletList.UserWalletListError
import com.tangem.tap.domain.userWalletList.UserWalletsListError
import com.tangem.tap.domain.userWalletList.UserWalletsListManager
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.repository.SelectedUserWalletRepository
@ -15,7 +16,6 @@ import com.tangem.tap.domain.userWalletList.utils.toUserWallets
import com.tangem.tap.domain.userWalletList.utils.updateWith
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class)
internal class BiometricUserWalletsListManager(
@ -53,9 +53,32 @@ internal class BiometricUserWalletsListManager(
override val hasUserWallets: Boolean
get() = keysRepository.hasSavedEncryptionKeys()
override suspend fun unlock(): CompletionResult<UserWallet?> {
override suspend fun unlock(): CompletionResult<UserWallet> {
// Occurs when user has locked user wallets after unlocking with biometry
// e.g. after biometric storage master key invalidation
if (state.value.hasLockedUserWalletsAfterUnlock) {
return CompletionResult.Failure(
error = UserWalletsListError.UnableToUnlockUserWallets(
cause = IllegalStateException("Permanently locked"),
),
)
}
return unlockWithBiometryInternal()
.map { selectedUserWalletSync }
.mapFailure { error ->
if (error is UserWalletsListError) {
error
} else {
UserWalletsListError.UnableToUnlockUserWallets(cause = error)
}
}
.map {
selectedUserWalletSync.guard {
throw UserWalletsListError.UnableToUnlockUserWallets(
cause = IllegalStateException("No user wallet selected"),
)
}
}
}
override fun lock() {
@ -88,7 +111,7 @@ internal class BiometricUserWalletsListManager(
}
if (isWalletSaved) {
CompletionResult.Failure(UserWalletListError.WalletAlreadySaved)
CompletionResult.Failure(UserWalletsListError.WalletAlreadySaved)
} else {
saveInternal(userWallet, changeSelectedUserWallet = true)
}
@ -188,8 +211,10 @@ internal class BiometricUserWalletsListManager(
.flatMap { loadModels() }
.map {
state.update { prevState ->
val hasLockedUserWallets = prevState.userWallets.any { it.isLocked }
prevState.copy(
isLocked = false,
isLocked = hasLockedUserWallets,
hasLockedUserWalletsAfterUnlock = hasLockedUserWallets,
)
}
}
@ -234,7 +259,13 @@ internal class BiometricUserWalletsListManager(
}
}
.doOnFailure { error ->
Timber.e(error, "Unable to load user wallets")
if (error is UserWalletsListError.InvalidEncryptionKey) {
state.update { prevState ->
prevState.copy(
hasLockedUserWalletsAfterUnlock = true,
)
}
}
}
}
@ -290,7 +321,7 @@ internal class BiometricUserWalletsListManager(
private fun findSelectedUserWallet(userWallets: List<UserWallet> = state.value.userWallets): UserWallet? {
return userWallets.firstOrNull {
it.walletId == state.value.selectedUserWalletId
it.walletId == state.value.selectedUserWalletId && !it.isLocked
}
}
@ -299,5 +330,6 @@ internal class BiometricUserWalletsListManager(
val userWallets: List<UserWallet> = emptyList(),
val selectedUserWalletId: UserWalletId? = null,
val isLocked: Boolean = true,
val hasLockedUserWalletsAfterUnlock: Boolean = false,
)
}

View file

@ -4,7 +4,7 @@ import com.tangem.common.CompletionResult
import com.tangem.common.catching
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.userWalletList.UserWalletListError
import com.tangem.tap.domain.userWalletList.UserWalletsListError
import com.tangem.tap.domain.userWalletList.UserWalletsListManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@ -49,7 +49,7 @@ internal class RuntimeUserWalletsListManager : UserWalletsListManager {
val isWalletSaved = state.value.userWallet?.walletId == userWallet.walletId
if (isWalletSaved) {
CompletionResult.Failure(UserWalletListError.WalletAlreadySaved)
CompletionResult.Failure(UserWalletsListError.WalletAlreadySaved)
} else {
saveInternal(userWallet)
}

View file

@ -8,10 +8,13 @@ import com.tangem.common.biometric.BiometricManager
import com.tangem.common.biometric.BiometricStorage
import com.tangem.common.core.TangemSdkError
import com.tangem.common.doOnFailure
import com.tangem.common.flatMapOnFailure
import com.tangem.common.fold
import com.tangem.common.map
import com.tangem.common.mapFailure
import com.tangem.common.services.secure.SecureStorage
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.domain.userWalletList.UserWalletsListError
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysRepository
import kotlinx.coroutines.Dispatchers
@ -37,6 +40,16 @@ internal class BiometricUserWalletsKeysRepository(
override suspend fun getAll(): CompletionResult<List<UserWalletEncryptionKey>> {
return withContext(Dispatchers.IO) {
getAllInternal()
.mapFailure { error ->
when (error) {
is TangemSdkError.BiometricsAuthenticationLockout ->
UserWalletsListError.BiometricsAuthenticationLockout(isPermanent = false)
is TangemSdkError.BiometricsAuthenticationPermanentLockout ->
UserWalletsListError.BiometricsAuthenticationLockout(isPermanent = true)
is TangemSdkError.InvalidEncryptionKey -> UserWalletsListError.InvalidEncryptionKey
else -> error
}
}
}
}
@ -79,12 +92,28 @@ internal class BiometricUserWalletsKeysRepository(
return getUserWalletsIds()
.map { userWalletId ->
// This is possible because the Card SDK cipher key has an expiration time
// If this operation runs more than that expiration time, the user will not receive all encryption keys
// If this operation runs more than that expiration time, the user will have to re-authorize
// to receive all keys
getEncryptionKey(userWalletId)
.flatMapOnFailure { error ->
// If key decryption failed then skip it
if (error is TangemSdkError.EncryptionOperationFailed) {
CompletionResult.Success(data = null)
} else {
CompletionResult.Failure(error)
}
}
.doOnFailure { error ->
// If the user cancels biometric authentication, cancel the request for all keys
if (error is TangemSdkError.UserCanceledBiometricsAuthentication) {
return CompletionResult.Failure(error)
when (error) {
is TangemSdkError.UserCanceledBiometricsAuthentication -> {
// If the user cancels biometric authentication, cancel the request for all keys
return CompletionResult.Failure(error)
}
is TangemSdkError.InvalidEncryptionKey -> {
if (error.isKeyRegenerated) {
deleteEncryptionKey(userWalletId)
}
}
}
}
}
@ -94,20 +123,20 @@ internal class BiometricUserWalletsKeysRepository(
}
private suspend fun getEncryptionKey(userWalletId: UserWalletId): CompletionResult<UserWalletEncryptionKey?> {
return biometricStorage.get(StorageKey.WalletEncryptionKey(userWalletId).name)
return biometricStorage.get(StorageKey.UserWalletEncryptionKey(userWalletId).name)
.map { it.decodeToKey() }
}
private suspend fun storeEncryptionKey(encryptionKey: UserWalletEncryptionKey): CompletionResult<Unit> {
return biometricStorage.store(
key = StorageKey.WalletEncryptionKey(encryptionKey.walletId).name,
key = StorageKey.UserWalletEncryptionKey(encryptionKey.walletId).name,
data = encryptionKey.encode(),
)
.map { storeUserWalletId(encryptionKey.walletId) }
}
private suspend fun deleteEncryptionKey(userWalletId: UserWalletId): CompletionResult<Unit> {
return biometricStorage.delete(StorageKey.WalletEncryptionKey(userWalletId).name)
return biometricStorage.delete(StorageKey.UserWalletEncryptionKey(userWalletId).name)
}
private suspend fun getUserWalletsIds(): List<UserWalletId> {
@ -175,7 +204,7 @@ internal class BiometricUserWalletsKeysRepository(
private sealed interface StorageKey {
val name: String
class WalletEncryptionKey(userWalletId: UserWalletId) : StorageKey {
class UserWalletEncryptionKey(userWalletId: UserWalletId) : StorageKey {
override val name: String = "user_wallet_encryption_key_${userWalletId.stringValue}"
}

View file

@ -25,11 +25,13 @@ import com.tangem.core.ui.fragments.ComposeBottomSheetFragment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.analytics.events.MyWallets
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.tap.features.walletSelector.ui.components.BiometricsLockoutDialogContent
import com.tangem.tap.features.walletSelector.ui.components.BiometricsLockoutWarningContent
import com.tangem.tap.features.walletSelector.ui.components.KeyInvalidatedWarningContent
import com.tangem.tap.features.walletSelector.ui.components.RemoveWalletDialogContent
import com.tangem.tap.features.walletSelector.ui.components.RenameWalletDialogContent
import com.tangem.tap.features.walletSelector.ui.components.WalletSelectorScreenContent
import com.tangem.tap.features.walletSelector.ui.model.DialogModel
import com.tangem.tap.features.walletSelector.ui.model.WarningModel
internal class WalletSelectorBottomSheetFragment : ComposeBottomSheetFragment<WalletSelectorScreenState>() {
private val viewModel by viewModels<WalletSelectorViewModel>()
@ -93,7 +95,8 @@ internal class WalletSelectorBottomSheetFragment : ComposeBottomSheetFragment<Wa
when (dialog) {
is DialogModel.RemoveWalletDialog -> RemoveWalletDialogContent(dialog)
is DialogModel.RenameWalletDialog -> RenameWalletDialogContent(dialog)
is DialogModel.BiometricsLockoutDialog -> BiometricsLockoutDialogContent(dialog)
is WarningModel.BiometricsLockoutWarning -> BiometricsLockoutWarningContent(dialog)
is WarningModel.KeyInvalidatedWarning -> KeyInvalidatedWarningContent(dialog)
}
}
}

View file

@ -3,11 +3,11 @@ package com.tangem.tap.features.walletSelector.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.common.analytics.events.MyWallets
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.domain.userWalletList.UserWalletsListError
import com.tangem.tap.domain.userWalletList.isLocked
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.walletSelector.redux.WalletSelectorAction
@ -15,6 +15,7 @@ import com.tangem.tap.features.walletSelector.redux.WalletSelectorState
import com.tangem.tap.features.walletSelector.ui.model.DialogModel
import com.tangem.tap.features.walletSelector.ui.model.MultiCurrencyUserWalletItem
import com.tangem.tap.features.walletSelector.ui.model.SingleCurrencyUserWalletItem
import com.tangem.tap.features.walletSelector.ui.model.WarningModel
import com.tangem.tap.store
import com.tangem.tap.userWalletsListManager
import com.tangem.tap.walletStoresManager
@ -159,7 +160,7 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
}
}
}
val biometricsLockoutDialog = createBiometricsLockoutDialogIfNeeded(state.error, prevState.dialog)
val warningDialog = createWarningDialogIfNeeded(state.error, prevState.dialog)
prevState.copy(
multiCurrencyWallets = multiCurrencyWallets,
@ -169,9 +170,9 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
isLocked = state.isLocked,
showUnlockProgress = state.isUnlockInProgress,
showAddCardProgress = state.isCardSavingInProgress,
dialog = biometricsLockoutDialog,
dialog = warningDialog,
error = state.error
?.takeIf { !it.silent && biometricsLockoutDialog == null }
?.takeIf { !it.silent && warningDialog == null }
?.let { error ->
error.messageResId?.let { TextReference.Res(it) }
?: TextReference.Str(error.customMessage)
@ -184,24 +185,23 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
store.unsubscribe(this)
}
private fun createBiometricsLockoutDialogIfNeeded(
private fun createWarningDialogIfNeeded(
error: TangemError?,
currentDialogModel: DialogModel?,
currentDialog: DialogModel?,
): DialogModel? {
return when (error) {
is TangemSdkError.BiometricsAuthenticationLockout -> DialogModel.BiometricsLockoutDialog(
isPermanent = false,
onDismiss = this::dismissDialog,
is UserWalletsListError.BiometricsAuthenticationLockout -> WarningModel.BiometricsLockoutWarning(
isPermanent = error.isPermanent,
onDismiss = this::dismissWarningDialog,
)
is TangemSdkError.BiometricsAuthenticationPermanentLockout -> DialogModel.BiometricsLockoutDialog(
isPermanent = true,
onDismiss = this::dismissDialog,
is UserWalletsListError.InvalidEncryptionKey -> WarningModel.KeyInvalidatedWarning(
onDismiss = this::dismissWarningDialog,
)
else -> currentDialogModel
else -> currentDialog
}
}
private fun dismissDialog() {
private fun dismissWarningDialog() {
stateInternal.update { prevState ->
prevState.copy(
dialog = null,

View file

@ -8,26 +8,26 @@ import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.walletSelector.ui.model.DialogModel
import com.tangem.tap.features.walletSelector.ui.model.WarningModel
import com.tangem.wallet.R
@Composable
internal fun BiometricsLockoutDialogContent(
dialog: DialogModel.BiometricsLockoutDialog,
internal fun BiometricsLockoutWarningContent(
warning: WarningModel.BiometricsLockoutWarning,
) {
BasicDialog(
title = stringResource(id = R.string.biometric_lockout_warning_title),
message = stringResource(
id = if (dialog.isPermanent) {
id = if (warning.isPermanent) {
R.string.biometric_lockout_permanent_warning_description
} else {
R.string.biometric_lockout_warning_description
},
),
onDismissDialog = dialog.onDismiss,
onDismissDialog = warning.onDismiss,
confirmButton = DialogButton(
title = stringResource(id = R.string.common_ok),
onClick = dialog.onDismiss,
onClick = warning.onDismiss,
),
)
}
@ -38,8 +38,8 @@ private fun BiometricsLockoutDialogSample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BiometricsLockoutDialogContent(
dialog = DialogModel.BiometricsLockoutDialog(
BiometricsLockoutWarningContent(
warning = WarningModel.BiometricsLockoutWarning(
isPermanent = false,
onDismiss = {},
),
@ -68,8 +68,8 @@ private fun BiometricsLockoutDialog_Permanent_Sample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BiometricsLockoutDialogContent(
dialog = DialogModel.BiometricsLockoutDialog(
BiometricsLockoutWarningContent(
warning = WarningModel.BiometricsLockoutWarning(
isPermanent = true,
onDismiss = {},
),

View file

@ -0,0 +1,56 @@
package com.tangem.tap.features.walletSelector.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.walletSelector.ui.model.WarningModel
import com.tangem.wallet.R
@Composable
internal fun KeyInvalidatedWarningContent(
warning: WarningModel.KeyInvalidatedWarning,
) {
BasicDialog(
title = stringResource(id = R.string.common_attention),
message = stringResource(id = R.string.key_invalidated_warning_description),
onDismissDialog = warning.onDismiss,
confirmButton = DialogButton(
title = stringResource(id = R.string.common_ok),
onClick = warning.onDismiss,
),
)
}
// region Preview
@Composable
private fun KeyInvalidatedWarningSample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
KeyInvalidatedWarningContent(
warning = WarningModel.KeyInvalidatedWarning(onDismiss = {}),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun KeyInvalidatedWarningPreview_Light() {
TangemTheme {
KeyInvalidatedWarningSample()
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun KeyInvalidatedWarningPreview_Dark() {
TangemTheme(isDark = true) {
KeyInvalidatedWarningSample()
}
}
// endregion Preview

View file

@ -11,9 +11,15 @@ internal sealed interface DialogModel {
val onConfirm: () -> Unit,
val onDismiss: () -> Unit,
) : DialogModel
}
data class BiometricsLockoutDialog(
internal sealed interface WarningModel : DialogModel {
data class BiometricsLockoutWarning(
val isPermanent: Boolean,
val onDismiss: () -> Unit,
) : DialogModel
) : WarningModel
data class KeyInvalidatedWarning(
val onDismiss: () -> Unit,
) : WarningModel
}

View file

@ -27,6 +27,7 @@ import com.tangem.tap.tangemSdkManager
import com.tangem.tap.userWalletsListManager
import kotlinx.coroutines.launch
import org.rekotlin.Middleware
import timber.log.Timber
internal class WelcomeMiddleware {
val middleware: Middleware<AppState> = { _, appStateProvider ->
@ -69,16 +70,15 @@ internal class WelcomeMiddleware {
scope.launch {
userWalletsListManager.unlockIfLockable()
.doOnFailure { error ->
Timber.e(error, "Unable to unlock user wallets with biometrics")
store.dispatchOnMain(WelcomeAction.ProceedWithBiometrics.Error(error))
}
.doOnSuccess { selectedUserWallet ->
if (selectedUserWallet != null) {
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet))
store.dispatchOnMain(WelcomeAction.ProceedWithBiometrics.Success)
store.onUserWalletSelected(selectedUserWallet)
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet))
store.dispatchOnMain(WelcomeAction.ProceedWithBiometrics.Success)
store.onUserWalletSelected(selectedUserWallet)
intentHandler.handleWalletConnectLink(state.intent)
}
intentHandler.handleWalletConnectLink(state.intent)
}
}
}
@ -89,6 +89,7 @@ internal class WelcomeMiddleware {
userWalletsListManager.save(userWallet, canOverride = true)
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")
store.dispatchOnMain(WelcomeAction.ProceedWithCard.Error(error))
}
.doOnSuccess {

View file

@ -24,7 +24,7 @@ import com.tangem.core.ui.fragments.ComposeFragment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.tap.features.welcome.redux.WelcomeAction
import com.tangem.tap.features.welcome.ui.components.BiometricsLockoutDialog
import com.tangem.tap.features.welcome.ui.components.WarningDialog
import com.tangem.tap.features.welcome.ui.components.WelcomeScreenContent
import com.tangem.tap.store
import com.tangem.wallet.R
@ -49,7 +49,7 @@ internal class WelcomeFragment : ComposeFragment<WelcomeScreenState>() {
) {
val snackbarHostState = remember { SnackbarHostState() }
val errorMessage by rememberUpdatedState(newValue = state.error?.resolveReference())
val biometricsLockoutDialog by rememberUpdatedState(newValue = state.biometricsLockoutDialog)
val warning by rememberUpdatedState(newValue = state.warning)
val backgroundColor = colorResource(id = R.color.background_primary)
SystemBarsEffect {
@ -80,7 +80,7 @@ internal class WelcomeFragment : ComposeFragment<WelcomeScreenState>() {
)
}
BiometricsLockoutDialog(biometricsLockoutDialog)
WarningDialog(warning)
LaunchedEffect(key1 = errorMessage) {
errorMessage?.let {

View file

@ -2,12 +2,12 @@ package com.tangem.tap.features.welcome.ui
import androidx.compose.runtime.Immutable
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.welcome.ui.model.BiometricsLockoutDialog
import com.tangem.tap.features.welcome.ui.model.WarningModel
@Immutable
internal data class WelcomeScreenState(
val showUnlockWithBiometricsProgress: Boolean = false,
val showUnlockWithCardProgress: Boolean = false,
val biometricsLockoutDialog: BiometricsLockoutDialog? = null,
val warning: WarningModel? = null,
val error: TextReference? = null,
)

View file

@ -2,14 +2,14 @@ package com.tangem.tap.features.welcome.ui
import androidx.lifecycle.ViewModel
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.Analytics
import com.tangem.tap.common.analytics.events.SignIn
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.userWalletList.UserWalletsListError
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.welcome.redux.WelcomeAction
import com.tangem.tap.features.welcome.redux.WelcomeState
import com.tangem.tap.features.welcome.ui.model.BiometricsLockoutDialog
import com.tangem.tap.features.welcome.ui.model.WarningModel
import com.tangem.tap.store
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -41,15 +41,15 @@ internal class WelcomeViewModel : ViewModel(), StoreSubscriber<WelcomeState> {
// TODO: Refactor errors handling
override fun newState(state: WelcomeState) {
val biometricsLockoutDialog = createBiometricLockoutDialogIfNeeded(state.error)
val warning = createWarningIfNeeded(state.error)
stateInternal.update { prevState ->
prevState.copy(
showUnlockWithBiometricsProgress = state.isUnlockWithBiometricsInProgress,
showUnlockWithCardProgress = state.isUnlockWithCardInProgress,
biometricsLockoutDialog = biometricsLockoutDialog,
warning = warning,
error = state.error
?.takeIf { !it.silent && biometricsLockoutDialog == null }
?.takeIf { !it.silent && warning == null }
?.let { e ->
e.messageResId?.let { TextReference.Res(it) }
?: TextReference.Str(e.customMessage)
@ -62,24 +62,23 @@ internal class WelcomeViewModel : ViewModel(), StoreSubscriber<WelcomeState> {
store.unsubscribe(this)
}
private fun createBiometricLockoutDialogIfNeeded(error: TangemError?): BiometricsLockoutDialog? {
private fun createWarningIfNeeded(error: TangemError?): WarningModel? {
return when (error) {
is TangemSdkError.BiometricsAuthenticationLockout -> BiometricsLockoutDialog(
isPermanent = false,
onDismiss = this::dismissDialog,
is UserWalletsListError.BiometricsAuthenticationLockout -> WarningModel.BiometricsLockoutWarning(
isPermanent = error.isPermanent,
onDismiss = this::dismissWarning,
)
is TangemSdkError.BiometricsAuthenticationPermanentLockout -> BiometricsLockoutDialog(
isPermanent = true,
onDismiss = this::dismissDialog,
is UserWalletsListError.InvalidEncryptionKey -> WarningModel.KeyInvalidatedWarning(
onDismiss = this::dismissWarning,
)
else -> null
}
}
private fun dismissDialog() {
private fun dismissWarning() {
stateInternal.update { prevState ->
prevState.copy(
biometricsLockoutDialog = null,
warning = null,
)
}
closeError()

View file

@ -1,96 +0,0 @@
package com.tangem.tap.features.welcome.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.welcome.ui.model.BiometricsLockoutDialog
import com.tangem.wallet.R
@Composable
internal fun BiometricsLockoutDialog(
dialog: BiometricsLockoutDialog?,
) {
if (dialog == null) return
BasicDialog(
title = stringResource(id = R.string.biometric_lockout_warning_title),
message = stringResource(
id = if (dialog.isPermanent) {
R.string.biometric_lockout_permanent_warning_description
} else {
R.string.biometric_lockout_warning_description
},
),
onDismissDialog = dialog.onDismiss,
confirmButton = DialogButton(
title = stringResource(id = R.string.common_ok),
onClick = dialog.onDismiss,
),
)
}
// region Preview
@Composable
private fun BiometricsLockoutDialogSample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BiometricsLockoutDialog(
dialog = BiometricsLockoutDialog(
isPermanent = false,
onDismiss = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialogPreview_Light() {
TangemTheme {
BiometricsLockoutDialogSample()
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialogPreview_Dark() {
TangemTheme(isDark = true) {
BiometricsLockoutDialogSample()
}
}
@Composable
private fun BiometricsLockoutDialog_Permanent_Sample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BiometricsLockoutDialog(
dialog = BiometricsLockoutDialog(
isPermanent = true,
onDismiss = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialog_Permanent_Preview_Light() {
TangemTheme {
BiometricsLockoutDialog_Permanent_Sample()
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialog_Permanent_Preview_Dark() {
TangemTheme(isDark = true) {
BiometricsLockoutDialog_Permanent_Sample()
}
}
// endregion Preview

View file

@ -0,0 +1,138 @@
package com.tangem.tap.features.welcome.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.welcome.ui.model.WarningModel
import com.tangem.wallet.R
@Composable
internal fun WarningDialog(
warning: WarningModel?,
) {
when (warning) {
null -> Unit
is WarningModel.BiometricsLockoutWarning -> {
BasicDialog(
title = stringResource(id = R.string.biometric_lockout_warning_title),
message = stringResource(
id = if (warning.isPermanent) {
R.string.biometric_lockout_permanent_warning_description
} else {
R.string.biometric_lockout_warning_description
},
),
onDismissDialog = warning.onDismiss,
confirmButton = DialogButton(
title = stringResource(id = R.string.common_ok),
onClick = warning.onDismiss,
),
)
}
is WarningModel.KeyInvalidatedWarning -> {
BasicDialog(
title = stringResource(id = R.string.common_attention),
message = stringResource(id = R.string.key_invalidated_warning_description),
onDismissDialog = warning.onDismiss,
confirmButton = DialogButton(
title = stringResource(id = R.string.common_ok),
onClick = warning.onDismiss,
),
)
}
}
}
// region Preview
@Composable
private fun BiometricsLockoutDialogSample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
WarningDialog(
warning = WarningModel.BiometricsLockoutWarning(
isPermanent = false,
onDismiss = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialogPreview_Light() {
TangemTheme {
BiometricsLockoutDialogSample()
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialogPreview_Dark() {
TangemTheme(isDark = true) {
BiometricsLockoutDialogSample()
}
}
@Composable
private fun BiometricsLockoutDialog_Permanent_Sample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
WarningDialog(
warning = WarningModel.BiometricsLockoutWarning(
isPermanent = true,
onDismiss = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialog_Permanent_Preview_Light() {
TangemTheme {
BiometricsLockoutDialog_Permanent_Sample()
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun BiometricsLockoutDialog_Permanent_Preview_Dark() {
TangemTheme(isDark = true) {
BiometricsLockoutDialog_Permanent_Sample()
}
}
// region Preview
@Composable
private fun KeyInvalidatedWarningSample(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
WarningDialog(warning = WarningModel.KeyInvalidatedWarning(onDismiss = {}))
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun KeyInvalidatedWarningPreview_Light() {
TangemTheme {
KeyInvalidatedWarningSample()
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun KeyInvalidatedWarningPreview_Dark() {
TangemTheme(isDark = true) {
KeyInvalidatedWarningSample()
}
}
// endregion Preview
// endregion Preview

View file

@ -1,6 +0,0 @@
package com.tangem.tap.features.welcome.ui.model
internal data class BiometricsLockoutDialog(
val isPermanent: Boolean,
val onDismiss: () -> Unit,
)

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.welcome.ui.model
internal sealed interface WarningModel {
data class BiometricsLockoutWarning(
val isPermanent: Boolean,
val onDismiss: () -> Unit,
) : WarningModel
data class KeyInvalidatedWarning(
val onDismiss: () -> Unit,
) : WarningModel
}

View file

@ -64,7 +64,7 @@ object Versions {
const val tangemBlockchainSdk = "develop-174"
// const val tangemBlockchainSdk = "0.0.1" // Keep it! - used for local builds
const val tangemCardSdk = "develop-199"
const val tangemCardSdk = "develop-200"
// const val tangemCardSdk = "0.0.1" // Keep it! - used for local builds
// endregion Tangem

View file

@ -339,7 +339,7 @@
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
<string name="swapping_not_enough_funds_for_fee">Not enough funds for fee in your %1$s wallet to create a transaction. Top up your %2$s wallet first.</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress...</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress</string>
<string name="swapping_pending_transaction_title">Waiting</string>
<string name="swapping_permission_buttons_approve">Approve</string>
<string name="swapping_permission_header">Give Permission</string>
@ -371,7 +371,7 @@
<string name="transaction_history_empty_transactions">You don\'t have any transactions yet</string>
<string name="transaction_history_error_failed_to_load">Failed to load transactions</string>
<string name="transaction_history_title">Transactions</string>
<string name="transaction_history_tx_in_progress">In progress...</string>
<string name="transaction_history_tx_in_progress">In progress</string>
<string name="twin_error_same_card">You\'ve scanned the same card. To create a twin wallet you need to scan the card with number %d</string>
<string name="twins_onboarding_description_format">This one that you are holding in your hands and the other one with number %s.\n\nBoth cards can be used to extract funds from this wallet.</string>
<string name="twins_onboarding_subtitle">One wallet. Two cards.</string>
@ -384,6 +384,7 @@
<string name="user_wallet_list_add_button">Add new wallet</string>
<string name="user_wallet_list_delete_prompt">Are you sure you want to delete this wallet?</string>
<string name="user_wallet_list_editing_count">%d selected</string>
<string name="user_wallet_list_error_unable_to_unlock">An error has occurred, please scan your card to log in</string>
<string name="user_wallet_list_error_wallet_already_saved">This wallet has already been saved, you can add another one</string>
<string name="user_wallet_list_multi_header">Multi-currency</string>
<string name="user_wallet_list_rename_popup_placeholder">Wallet name</string>

View file

@ -339,7 +339,7 @@
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
<string name="swapping_not_enough_funds_for_fee">Not enough funds for fee in your %1$s wallet to create a transaction. Top up your %2$s wallet first.</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress...</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress</string>
<string name="swapping_pending_transaction_title">Waiting</string>
<string name="swapping_permission_buttons_approve">Approve</string>
<string name="swapping_permission_header">Give Permission</string>
@ -371,7 +371,7 @@
<string name="transaction_history_empty_transactions">You don\'t have any transactions yet</string>
<string name="transaction_history_error_failed_to_load">Failed to load transactions</string>
<string name="transaction_history_title">Transactions</string>
<string name="transaction_history_tx_in_progress">In progress...</string>
<string name="transaction_history_tx_in_progress">In progress</string>
<string name="twin_error_same_card">You\'ve scanned the same card. To create a twin wallet you need to scan the card with number %d</string>
<string name="twins_onboarding_description_format">This one that you are holding in your hands and the other one with number %s.\n\nBoth cards can be used to extract funds from this wallet.</string>
<string name="twins_onboarding_subtitle">One wallet. Two cards.</string>
@ -384,6 +384,7 @@
<string name="user_wallet_list_add_button">Add new wallet</string>
<string name="user_wallet_list_delete_prompt">Are you sure you want to delete this wallet?</string>
<string name="user_wallet_list_editing_count">%d selected</string>
<string name="user_wallet_list_error_unable_to_unlock">An error has occurred, please scan your card to log in</string>
<string name="user_wallet_list_error_wallet_already_saved">This wallet has already been saved, you can add another one</string>
<string name="user_wallet_list_multi_header">Multi-currency</string>
<string name="user_wallet_list_rename_popup_placeholder">Wallet name</string>

View file

@ -339,7 +339,7 @@
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
<string name="swapping_not_enough_funds_for_fee">Not enough funds for fee in your %1$s wallet to create a transaction. Top up your %2$s wallet first.</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress...</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress</string>
<string name="swapping_pending_transaction_title">Waiting</string>
<string name="swapping_permission_buttons_approve">Approve</string>
<string name="swapping_permission_header">Give Permission</string>
@ -371,7 +371,7 @@
<string name="transaction_history_empty_transactions">You don\'t have any transactions yet</string>
<string name="transaction_history_error_failed_to_load">Failed to load transactions</string>
<string name="transaction_history_title">Transactions</string>
<string name="transaction_history_tx_in_progress">In progress...</string>
<string name="transaction_history_tx_in_progress">In progress</string>
<string name="twin_error_same_card">You\'ve scanned the same card. To create a twin wallet you need to scan the card with number %d</string>
<string name="twins_onboarding_description_format">This one that you are holding in your hands and the other one with number %s.\n\nBoth cards can be used to extract funds from this wallet.</string>
<string name="twins_onboarding_subtitle">One wallet. Two cards.</string>
@ -384,6 +384,7 @@
<string name="user_wallet_list_add_button">Add new wallet</string>
<string name="user_wallet_list_delete_prompt">Are you sure you want to delete this wallet?</string>
<string name="user_wallet_list_editing_count">%d selected</string>
<string name="user_wallet_list_error_unable_to_unlock">An error has occurred, please scan your card to log in</string>
<string name="user_wallet_list_error_wallet_already_saved">This wallet has already been saved, you can add another one</string>
<string name="user_wallet_list_multi_header">Multi-currency</string>
<string name="user_wallet_list_rename_popup_placeholder">Wallet name</string>

View file

@ -132,6 +132,7 @@
<string name="initial_message_sign_header">Нажмите, чтобы подписать</string>
<string name="initial_message_tap_header">Приложите карту</string>
<string name="internal_error_wallet_manager_not_found">Внутренняя ошибка: не удается найти менеджер кошельков</string>
<string name="key_invalidated_warning_description">Вы обновили данные биометрии, отсканируйте свою карту для входа</string>
<string name="main_manage_tokens">Управление токенами</string>
<string name="main_no_backup_warning_subtitle">Чтобы защитить свои активы, мы советуем вам выполнить эту процедуру</string>
<string name="main_no_backup_warning_title">Бэкап кошелька не был произведен</string>
@ -384,6 +385,7 @@
<string name="user_wallet_list_add_button">Добавить новый кошелек</string>
<string name="user_wallet_list_delete_prompt">Вы уверены, что хотите удалить этот кошелек?</string>
<string name="user_wallet_list_editing_count">%d выбрано</string>
<string name="user_wallet_list_error_unable_to_unlock">Произошла ошибка, пожалуйста, отсканируйте свою карту для входа</string>
<string name="user_wallet_list_error_wallet_already_saved">Этот кошелек уже был сохранен, вы можете добавить другой</string>
<string name="user_wallet_list_multi_header">Мультивалютные</string>
<string name="user_wallet_list_rename_popup_placeholder">Имя кошелька</string>
@ -418,6 +420,7 @@
<string name="wallet_connect_error_timeout">Не удалось установить сессию WalletConnect за отведённое время. Пожалуйста, повторите попытку позже.</string>
<string name="wallet_connect_error_unsupported_blockchains">Запрос на подключение через WalletConnect содержит неподдерживаемые блокчеины. Неподдерживаемые блокчеины:\n</string>
<string name="wallet_connect_error_unsupported_dapp">Cоединение с этим Dapp сервисом не может быть установлено из-за его технической реализации.</string>
<string name="wallet_connect_error_wrong_card_selected">Неверная карта выбрана в приложении Tangem</string>
<string name="wallet_connect_generic_error_with_code">Произошла непредвиденная ошибка. Код ошибки: %d Попробуйте, пожалуйста, позже. Если проблема будет продолжать возникать - обратитесь в службу поддержки.</string>
<string name="wallet_connect_network_not_found_format">Сеть %s не найдена. Пожалуйста, добавьте её и попробуйте заново.</string>
<string name="wallet_connect_no_sessions_message">Нет открытых сессий WalletConnect</string>

View file

@ -339,7 +339,7 @@
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">餘額不足</string>
<string name="swapping_not_enough_funds_for_fee">您的 %1$s 錢包中沒有足夠的資金來創建交易。首先為您的 %2$s 錢包充值</string>
<string name="swapping_pending_transaction_subtitle">交易進行中...</string>
<string name="swapping_pending_transaction_subtitle">交易進行中</string>
<string name="swapping_pending_transaction_title">等待中</string>
<string name="swapping_permission_buttons_approve">允許</string>
<string name="swapping_permission_header">賦予權限</string>
@ -371,7 +371,7 @@
<string name="transaction_history_empty_transactions">您還沒有任何交易</string>
<string name="transaction_history_error_failed_to_load">無法加載交易</string>
<string name="transaction_history_title">交易</string>
<string name="transaction_history_tx_in_progress">進行中...</string>
<string name="transaction_history_tx_in_progress">進行中</string>
<string name="twin_error_same_card">您掃描了同一張卡片。要創建雙錢包,您需要掃描編號為 %d 的卡</string>
<string name="twins_onboarding_description_format">這一個是你手裡拿著的,另一個是編號為 %s 的,這兩張卡都可以用來從這個錢包中提取資金</string>
<string name="twins_onboarding_subtitle">一個錢包,兩張卡片</string>
@ -384,6 +384,7 @@
<string name="user_wallet_list_add_button">添加新錢包</string>
<string name="user_wallet_list_delete_prompt">您確定要刪除此錢包?</string>
<string name="user_wallet_list_editing_count">已選擇 %d</string>
<string name="user_wallet_list_error_unable_to_unlock">An error has occurred, please scan your card to log in</string>
<string name="user_wallet_list_error_wallet_already_saved">此錢包已保存,您可以再添加一個</string>
<string name="user_wallet_list_multi_header">多幣種</string>
<string name="user_wallet_list_rename_popup_placeholder">錢包名稱</string>

View file

@ -132,6 +132,7 @@
<string name="initial_message_sign_header">Tap to sign</string>
<string name="initial_message_tap_header">Tap the card</string>
<string name="internal_error_wallet_manager_not_found">Internal error: wallet manager not found</string>
<string name="key_invalidated_warning_description">You have updated biometrics, scan your card to enter</string>
<string name="main_manage_tokens">Manage tokens</string>
<string name="main_no_backup_warning_subtitle">To protect your assets, we advise you to carry out this procedure</string>
<string name="main_no_backup_warning_title">Your wallet has not been backed up</string>
@ -371,7 +372,7 @@
<string name="transaction_history_empty_transactions">You don\'t have any transactions yet</string>
<string name="transaction_history_error_failed_to_load">Failed to load transactions</string>
<string name="transaction_history_title">Transactions</string>
<string name="transaction_history_tx_in_progress">In progress...</string>
<string name="transaction_history_tx_in_progress">In progress</string>
<string name="twin_error_same_card">You\'ve scanned the same card. To create a twin wallet you need to scan the card with number %d</string>
<string name="twins_onboarding_description_format">This one that you are holding in your hands and the other one with number %s.\n\nBoth cards can be used to extract funds from this wallet.</string>
<string name="twins_onboarding_subtitle">One wallet. Two cards.</string>
@ -384,6 +385,7 @@
<string name="user_wallet_list_add_button">Add new wallet</string>
<string name="user_wallet_list_delete_prompt">Are you sure you want to delete this wallet?</string>
<string name="user_wallet_list_editing_count">%d selected</string>
<string name="user_wallet_list_error_unable_to_unlock">An error has occurred, please scan your card to log in</string>
<string name="user_wallet_list_error_wallet_already_saved">This wallet has already been saved, you can add another one</string>
<string name="user_wallet_list_multi_header">Multi-currency</string>
<string name="user_wallet_list_rename_popup_placeholder">Wallet name</string>
@ -418,6 +420,7 @@
<string name="wallet_connect_error_timeout">Failed to establish WalletConnect session: timeout error. Please, try again later.</string>
<string name="wallet_connect_error_unsupported_blockchains">Session request contains unsupported blockchains for WalletConnect connection. Unsupported blockchains:\n</string>
<string name="wallet_connect_error_unsupported_dapp">Connection with this Dapp cannot be established due to its technical implementation.</string>
<string name="wallet_connect_error_wrong_card_selected">Wrong card selected in Tangem App</string>
<string name="wallet_connect_generic_error_with_code">We\'ve encountered unknown error. Error code: %d. If the problem persists — feel free to contact our support</string>
<string name="wallet_connect_network_not_found_format">%s network not found. Please, add it first and try again.</string>
<string name="wallet_connect_no_sessions_message">No opened WalletConnect sessions</string>