Updated on 2026-08-14

This commit is contained in:
Tangem 2023-03-10 16:11:35 +03:00
commit a5a280e9b3
43 changed files with 544 additions and 256 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

@ -102,6 +102,7 @@ internal fun WalletDataModel.updateWithAmount(wallet: Wallet): WalletDataModel {
internal fun WalletDataModel.updateWithDemoAmount(wallet: Wallet): WalletDataModel {
val amount = DemoHelper.config.getBalance(wallet.blockchain)
wallet.setAmount(amount)
return this.copy(
status = WalletDataModel.VerifiedOnline(amount = amount.value ?: BigDecimal.ZERO),
)

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.wallet.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.common.extensions.isZero
import com.tangem.domain.common.extensions.toNetworkId
@ -37,6 +38,7 @@ data class WalletData(
fun isAvailableToSwap(swapInteractor: SwapInteractor, isExchangeFeatureOn: Boolean): Boolean {
return swapInteractor.isAvailableToSwap(currency.blockchain.toNetworkId()) &&
currency.blockchain.id != Blockchain.Optimism.id && // disable optimism blockchain for now
isExchangeFeatureOn &&
!currency.isCustomCurrency(null)
}

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

@ -59,12 +59,19 @@ class DerivationManagerImpl(
}
}
override fun hasDerivation(networkId: String): Boolean {
override fun getDerivationPathForBlockchain(networkId: String): String? {
val scanResponse = appStateHolder.scanResponse
val blockchain = Blockchain.fromNetworkId(networkId)
if (scanResponse != null && blockchain != null) {
return blockchain.derivationPath(appStateHolder.getActualCard()?.derivationStyle)?.rawPath
}
return null
}
override fun hasDerivation(networkId: String, derivationPath: String): Boolean {
val scanResponse = appStateHolder.scanResponse
val blockchain = Blockchain.fromNetworkId(networkId)
if (scanResponse != null && blockchain != null) {
val derivationPath = blockchain.derivationPath(appStateHolder.getActualCard()?.derivationStyle)?.rawPath
if (derivationPath.isNullOrEmpty()) return false
return scanResponse.hasDerivation(
blockchain,
derivationPath,

View file

@ -148,6 +148,7 @@ class TransactionManagerImpl(
amountToSend: BigDecimal,
currencyToSend: Currency,
destinationAddress: String,
increaseBy: Int?,
data: String?,
derivationPath: String?,
): ProxyFee {
@ -161,7 +162,13 @@ class TransactionManagerImpl(
currency = currencyToSend,
destinationAddress = destinationAddress,
data = data,
)
).let {
if (increaseBy != null && increaseBy != 0) {
it.multiply(increaseBy.toBigInteger()).divide(BigInteger("100"))
} else {
it
}
}
return when (val gasPrice = walletManager.getGasPrice()) {
is Result.Success -> {
val fee = gasLimit.multiply(gasPrice.data).toBigDecimal(

View file

@ -28,7 +28,11 @@ class UserWalletManagerImpl(
private val appStateHolder: AppStateHolder,
) : UserWalletManager {
override suspend fun getUserTokens(networkId: String, isExcludeCustom: Boolean): List<Currency> {
override suspend fun getUserTokens(
networkId: String,
derivationPath: String?,
isExcludeCustom: Boolean,
): List<Currency> {
val card = appStateHolder.getActualCard()
val userTokensRepository =
requireNotNull(appStateHolder.userTokensRepository) { "userTokensRepository is null" }
@ -40,7 +44,9 @@ class UserWalletManagerImpl(
} else {
true
}
it.blockchain.toNetworkId() == networkId && checkCustom
it.blockchain.toNetworkId() == networkId &&
checkCustom &&
it.derivationPath == derivationPath
}
.map {
if (it is com.tangem.tap.features.wallet.models.Currency.Token) {
@ -87,9 +93,13 @@ class UserWalletManagerImpl(
override suspend fun isTokenAdded(currency: Currency, derivationPath: String?): Boolean {
val blockchain = requireNotNull(Blockchain.fromNetworkId(currency.networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain, derivationPath)
return walletManager.cardTokens.any {
it.id == currency.id
return try {
val walletManager = getActualWalletManager(blockchain, derivationPath)
walletManager.cardTokens.any {
it.id == currency.id
}
} catch (e: IllegalArgumentException) {
false
}
}
@ -185,6 +195,7 @@ class UserWalletManagerImpl(
appStateHolder.mainStore?.dispatchOnMain(WalletAction.LoadData.Refresh)
}
@kotlin.jvm.Throws(IllegalArgumentException::class)
private fun getActualWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager {
val blockchainNetwork = BlockchainNetwork(blockchain, derivationPath, emptyList())
return requireNotNull(appStateHolder.walletState?.getWalletManager(blockchainNetwork)) {

View file

@ -67,7 +67,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>

View file

@ -18,7 +18,7 @@ default_platform(:android)
platform :android do
before_all do |lane, options|
FileUtils.mv("../app/src/main/assets/tangem-app-config/android/google-services.json", "../app")
FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services.json", "../app")
end
desc "Runs all the tests"

View file

@ -25,8 +25,8 @@ internal class ReferralInteractorImpl(
override suspend fun startReferral(): ReferralData {
if (tokensForReferral.isNotEmpty()) {
val currency = tokensConverter.convert(tokensForReferral.first())
deriveOrAddTokens(currency)
val publicAddress = userWalletManager.getWalletAddress(currency.networkId, null)
val derivationPath = deriveOrAddTokens(currency)
val publicAddress = userWalletManager.getWalletAddress(currency.networkId, derivationPath)
return repository.startReferral(
walletId = userWalletManager.getWalletId(),
networkId = currency.networkId,
@ -38,13 +38,16 @@ internal class ReferralInteractorImpl(
}
}
private suspend fun deriveOrAddTokens(currency: Currency) {
if (!derivationManager.hasDerivation(currency.networkId)) {
private suspend fun deriveOrAddTokens(currency: Currency): String {
val derivationPath = derivationManager.getDerivationPathForBlockchain(currency.networkId)
if (derivationPath.isNullOrEmpty()) error("derivationPath shouldn't be empty")
if (!derivationManager.hasDerivation(currency.networkId, derivationPath)) {
derivationManager.deriveMissingBlockchains(currency)
}
if (!userWalletManager.isTokenAdded(currency, null)) {
userWalletManager.addToken(currency, null)
if (!userWalletManager.isTokenAdded(currency, derivationPath)) {
userWalletManager.addToken(currency, derivationPath)
}
return derivationPath
}
private fun saveRefTokens(tokens: List<TokenData>) {

View file

@ -94,9 +94,10 @@ interface SwapInteractor {
/**
* Returns token in wallet balance
*
* @param networkId
* @param token
*/
fun getTokenBalance(token: Currency): SwapAmount
fun getTokenBalance(networkId: String, token: Currency): SwapAmount
fun isAvailableToSwap(networkId: String): Boolean
}

View file

@ -56,14 +56,18 @@ internal class SwapInteractorImpl @Inject constructor(
// replace tokens in wallet tokens list with loaded same
val loadedOnWalletsMap = mutableSetOf<String>()
val tokensInWallet = userWalletManager.getUserTokens(networkId = networkId, isExcludeCustom = true)
.filter { it.symbol != initialCurrency.symbol }
.map { token ->
allLoadedTokens.firstOrNull { it.symbol == token.symbol }?.let {
loadedOnWalletsMap.add(it.symbol)
it
} ?: cryptoCurrencyConverter.convertBack(token)
}
val tokensInWallet =
userWalletManager.getUserTokens(
networkId = networkId,
derivationPath = derivationPath,
isExcludeCustom = true,
).filter { it.symbol != initialCurrency.symbol }
.map { token ->
allLoadedTokens.firstOrNull { it.symbol == token.symbol }?.let {
loadedOnWalletsMap.add(it.symbol)
it
} ?: cryptoCurrencyConverter.convertBack(token)
}
val loadedTokens = allLoadedTokens
.filter {
!loadedOnWalletsMap.contains(it.symbol)
@ -72,7 +76,7 @@ internal class SwapInteractorImpl @Inject constructor(
.mapValues { SwapAmount(it.value.value, it.value.decimals) }
val appCurrency = userWalletManager.getUserAppCurrency()
val rates = repository.getRates(appCurrency.code, tokensInWallet.map { it.id })
cache.cacheBalances(tokensBalance)
cache.cacheBalances(networkId, derivationPath, tokensBalance)
cache.cacheLoadedTokens(loadedTokens.map { TokenWithBalance(it) })
cache.cacheInWalletTokens(getTokensWithBalance(tokensInWallet, tokensBalance, rates, appCurrency))
return TokensDataState(
@ -147,7 +151,7 @@ internal class SwapInteractorImpl @Inject constructor(
syncWalletBalanceForTokens(networkId, listOf(fromToken, toToken))
val amountDecimal = toBigDecimalOrNull(amountToSwap)
if (amountDecimal == null || amountDecimal.compareTo(BigDecimal.ZERO) == 0) {
return createEmptyAmountState(fromToken, toToken)
return createEmptyAmountState(networkId, fromToken, toToken)
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken))
val fromTokenAddress = getTokenAddress(fromToken)
@ -157,7 +161,7 @@ internal class SwapInteractorImpl @Inject constructor(
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
transactionManager.updateWalletManager(networkId, derivationPath)
}
val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null)
val isBalanceWithoutFeeEnough = isBalanceEnough(networkId, fromToken, amount, null)
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
loadSwapData(
networkId = networkId,
@ -224,8 +228,12 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
override fun getTokenBalance(token: Currency): SwapAmount {
return cache.getBalanceForToken(token.symbol) ?: SwapAmount(BigDecimal.ZERO, getTokenDecimals(token))
override fun getTokenBalance(networkId: String, token: Currency): SwapAmount {
return cache.getBalanceForToken(
networkId = networkId,
derivationPath = derivationPath,
symbol = token.symbol,
) ?: SwapAmount(BigDecimal.ZERO, getTokenDecimals(token))
}
override fun isAvailableToSwap(networkId: String): Boolean {
@ -298,12 +306,13 @@ internal class SwapInteractorImpl @Inject constructor(
}
private fun createEmptyAmountState(
networkId: String,
fromToken: Currency,
toToken: Currency,
): SwapState {
val appCurrency = userWalletManager.getUserAppCurrency()
val fromTokenBalance = cache.getBalanceForToken(fromToken.symbol)
val toTokenBalance = cache.getBalanceForToken(toToken.symbol)
val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol)
val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol)
return SwapState.EmptyAmountState(
fromTokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(),
toTokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(),
@ -402,6 +411,7 @@ internal class SwapInteractorImpl @Inject constructor(
amountToSend = amount.value,
currencyToSend = cryptoCurrencyConverter.convert(fromToken),
destinationAddress = swapData.transaction.toWalletAddress,
increaseBy = INCREASE_GAS_LIMIT_BY,
data = swapData.transaction.data,
derivationPath = derivationPath,
)
@ -411,7 +421,7 @@ internal class SwapInteractorImpl @Inject constructor(
decimals = transactionManager.getNativeTokenDecimals(networkId),
currency = userWalletManager.getNetworkCurrency(networkId),
) + feeFiat
val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeData.fee.value)
val isBalanceIncludeFeeEnough = isBalanceEnough(networkId, fromToken, amount, feeData.fee.value)
val isFeeEnough = checkFeeIsEnough(
fee = feeData.fee.value,
spendAmount = amount,
@ -458,8 +468,8 @@ internal class SwapInteractorImpl @Inject constructor(
val appCurrency = userWalletManager.getUserAppCurrency()
val nativeToken = userWalletManager.getNativeTokenForNetwork(networkId)
val rates = repository.getRates(appCurrency.code, listOf(fromToken.id, toToken.id, nativeToken.id))
val fromTokenBalance = cache.getBalanceForToken(fromToken.symbol)
val toTokenBalance = cache.getBalanceForToken(toToken.symbol)
val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol)
val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = fromTokenAmount,
@ -503,7 +513,7 @@ internal class SwapInteractorImpl @Inject constructor(
quotesLoadedState: SwapState.QuotesLoadedState,
): SwapState.QuotesLoadedState {
// if token balance ZERO not show permission state to avoid user to spend money for fee
val isTokenZeroBalance = getTokenBalance(fromToken).value.compareTo(BigDecimal.ZERO) == 0
val isTokenZeroBalance = getTokenBalance(networkId, fromToken).value.compareTo(BigDecimal.ZERO) == 0
if (isTokenZeroBalance) {
return quotesLoadedState.copy(
permissionState = PermissionDataState.Empty,
@ -520,6 +530,7 @@ internal class SwapInteractorImpl @Inject constructor(
amountToSend = BigDecimal.ZERO,
currencyToSend = userWalletManager.getNativeTokenForNetwork(networkId),
destinationAddress = transactionData.toAddress,
increaseBy = INCREASE_GAS_LIMIT_BY,
data = transactionData.data,
derivationPath = derivationPath,
)
@ -546,7 +557,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
private suspend fun syncWalletBalanceForTokens(networkId: String, tokens: List<Currency>) {
val tokensToSync = tokens.filter { cache.getBalanceForToken(it.symbol) == null }
val tokensToSync = tokens.filter { cache.getBalanceForToken(networkId, derivationPath, it.symbol) == null }
if (tokensToSync.isNotEmpty()) {
val tokensBalance =
userWalletManager.getCurrentWalletTokensBalance(
@ -554,12 +565,21 @@ internal class SwapInteractorImpl @Inject constructor(
extraTokens = tokensToSync.map { cryptoCurrencyConverter.convert(it) },
derivationPath = derivationPath,
)
cache.cacheBalances(tokensBalance.mapValues { SwapAmount(it.value.value, it.value.decimals) })
cache.cacheBalances(
networkId = networkId,
derivationPath = derivationPath,
balances = tokensBalance.mapValues { SwapAmount(it.value.value, it.value.decimals) },
)
}
}
private fun isBalanceEnough(fromToken: Currency, amount: SwapAmount, fee: BigDecimal?): Boolean {
val tokenBalance = getTokenBalance(fromToken).value
private fun isBalanceEnough(
networkId: String,
fromToken: Currency,
amount: SwapAmount,
fee: BigDecimal?,
): Boolean {
val tokenBalance = getTokenBalance(networkId, fromToken).value
return if (fromToken is Currency.NonNativeToken) {
tokenBalance >= amount.value
} else {
@ -627,6 +647,7 @@ internal class SwapInteractorImpl @Inject constructor(
private const val ZERO_BALANCE = "0"
private const val DEFAULT_BLOCKCHAIN_INCH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
private const val INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT = 1.4
private const val INCREASE_GAS_LIMIT_BY = 125
private const val USDT_SYMBOL = "USDT"
private const val USDC_SYMBOL = "USDC"
private const val INFINITY_SYMBOL = ""

View file

@ -10,11 +10,11 @@ interface SwapDataCache {
fun cacheAvailableToSwapTokens(networkId: String, tokens: List<Currency>)
fun cacheInWalletTokens(tokens: List<TokenWithBalance>)
fun cacheLoadedTokens(tokens: List<TokenWithBalance>)
fun cacheBalances(balances: Map<String, SwapAmount>)
fun cacheBalances(networkId: String, derivationPath: String?, balances: Map<String, SwapAmount>)
fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String)
fun getAvailableTokens(networkId: String): List<Currency>
fun getInWalletTokens(): List<TokenWithBalance>
fun getLoadedTokens(): List<TokenWithBalance>
fun getBalanceForToken(symbol: String): SwapAmount?
fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount?
fun getLastFeeForNetwork(networkId: String): BigDecimal?
}

View file

@ -9,7 +9,7 @@ class SwapDataCacheImpl : SwapDataCache {
private val availableTokensForNetwork: MutableMap<String, List<Currency>> = mutableMapOf()
private val feesForNetworks: MutableMap<String, BigDecimal> = mutableMapOf()
private val tokensBalances: MutableMap<String, SwapAmount> = mutableMapOf()
private val tokensBalances: MutableMap<String, Map<String, SwapAmount>> = mutableMapOf()
private val lastInWalletTokens = mutableListOf<TokenWithBalance>()
private val lastLoadedTokens = mutableListOf<TokenWithBalance>()
@ -35,12 +35,12 @@ class SwapDataCacheImpl : SwapDataCache {
return lastLoadedTokens
}
override fun getBalanceForToken(symbol: String): SwapAmount? {
return tokensBalances[symbol]
override fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount? {
return tokensBalances[createKeyFrom(networkId, derivationPath)]?.get(symbol)
}
override fun cacheBalances(balances: Map<String, SwapAmount>) {
tokensBalances.putAll(balances)
override fun cacheBalances(networkId: String, derivationPath: String?, balances: Map<String, SwapAmount>) {
tokensBalances[createKeyFrom(networkId, derivationPath)] = balances
}
override fun cacheAvailableToSwapTokens(networkId: String, tokens: List<Currency>) {
@ -54,4 +54,8 @@ class SwapDataCacheImpl : SwapDataCache {
override fun getAvailableTokens(networkId: String): List<Currency> {
return availableTokensForNetwork.getOrElse(networkId) { emptyList() }
}
private fun createKeyFrom(networkId: String, derivationPath: String?): String {
return "$networkId;$derivationPath"
}
}

View file

@ -371,7 +371,7 @@ internal class SwapViewModel @Inject constructor(
private fun onMaxAmountClicked() {
dataState.fromCurrency?.let {
val balance = swapInteractor.getTokenBalance(it)
val balance = swapInteractor.getTokenBalance(currency.networkId, it)
onAmountChanged(balance.formatToUIRepresentation())
}
}

View file

@ -12,7 +12,14 @@ interface DerivationManager {
suspend fun deriveMissingBlockchains(currency: Currency): Boolean
/**
* Checks that given [networkId] has derivations
* Returns derivationPath or null for blockchain with given networkId
*
* @param networkId
*/
fun hasDerivation(networkId: String): Boolean
fun getDerivationPathForBlockchain(networkId: String): String?
/**
* Checks that given [networkId] has derivations for [derivationPath]
*/
fun hasDerivation(networkId: String, derivationPath: String): Boolean
}

View file

@ -33,6 +33,18 @@ interface TransactionManager {
derivationPath: String?,
): SendTxResult
/**
* Get fee
*
* @param networkId network id of blockchain
* @param amountToSend amount
* @param currencyToSend currency to send in tx
* @param destinationAddress address to send tx
* @param increaseBy percents in format 125 = 25%
* @param data data for tx
* @param derivationPath derivation path
* @return
*/
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun getFee(
@ -40,6 +52,7 @@ interface TransactionManager {
amountToSend: BigDecimal,
currencyToSend: Currency,
destinationAddress: String,
increaseBy: Int?,
data: String?,
derivationPath: String?,
): ProxyFee

View file

@ -13,7 +13,7 @@ interface UserWalletManager {
* Returns all user tokens (merged from local and backend)
*/
@Throws(IllegalStateException::class)
suspend fun getUserTokens(networkId: String, isExcludeCustom: Boolean): List<Currency>
suspend fun getUserTokens(networkId: String, derivationPath: String?, isExcludeCustom: Boolean): List<Currency>
@Throws(IllegalStateException::class)
fun getNativeTokenForNetwork(networkId: String): Currency
@ -55,6 +55,7 @@ interface UserWalletManager {
* Return balances from wallet found by networkId
*
* @param networkId
* @param extraTokens tokens you want to check balance that not exists in wallet
* @param derivationPath if null uses default
* @return map of <Symbol, [ProxyAmount]>
*/