Updated on 2026-08-14

This commit is contained in:
Tangem 2023-01-10 10:42:58 +03:00
commit 5c71b53ea6
57 changed files with 586 additions and 432 deletions

View file

@ -3,11 +3,11 @@ package com.tangem.tap.common.analytics.converters
import com.tangem.common.Converter
import com.tangem.common.extensions.isZero
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.userWalletId
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.analytics.filters.BasicTopUpFilter
import com.tangem.tap.domain.extensions.isMultiwalletAllowed
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletData
import com.tangem.tap.features.wallet.redux.WalletState
@ -29,7 +29,9 @@ class BasicSignInEventConverter(
currency = cardCurrency,
batch = scanResponse.card.batchId,
).apply {
filterData = scanResponse.card.userWalletId.stringValue
filterData = UserWalletIdBuilder.scanResponse(scanResponse)
.build()
?.stringValue
}
}
}
@ -43,7 +45,7 @@ class BasicTopUpEventConverter(
val cardCurrency = ParamCardCurrencyConverter().convert(scanResponse) ?: return null
val data = BasicTopUpFilter.Data(
walletId = scanResponse.card.userWalletId.stringValue,
walletId = UserWalletIdBuilder.scanResponse(scanResponse).build()?.stringValue ?: "",
cardBalanceState = AnalyticsParam.CardBalanceState.from(value.walletsDataFromStores),
)

View file

@ -10,8 +10,8 @@ import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.address.Address
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.userWalletId
import com.tangem.tap.common.extensions.stripZeroPlainString
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
class AdditionalFeedbackInfo {
class EmailWalletInfo(
@ -54,7 +54,7 @@ class AdditionalFeedbackInfo {
cardFirmwareVersion = data.card.firmwareVersion.stringValue
cardIssuer = data.card.issuer.name
signedHashesCount = formatSignedHashes(data.card.wallets)
userWalletId = data.card.userWalletId.stringValue
userWalletId = UserWalletIdBuilder.scanResponse(data).build()?.stringValue ?: ""
}
fun setWalletsInfo(walletManagers: List<WalletManager>) {

View file

@ -57,11 +57,8 @@ val navigationMiddleware: Middleware<AppState> = { _, state ->
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
Settings.ACTION_BIOMETRIC_ENROLL
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
Settings.ACTION_FINGERPRINT_ENROLL
}
else -> {
Settings.ACTION_SETTINGS
Settings.ACTION_SECURITY_SETTINGS
}
}
val intent = Intent(settingsAction).apply {

View file

@ -234,6 +234,11 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
}
}
fun useBiometricsForAccessCode(): Boolean {
val policy = tangemSdk.config.userCodeRequestPolicy
return policy is UserCodeRequestPolicy.AlwaysWithBiometrics && policy.codeType == UserCodeType.AccessCode
}
companion object {
val config = Config(
linkedTerminal = true,

View file

@ -1,6 +1,5 @@
package com.tangem.tap.domain.model
import com.tangem.common.extensions.toHexString
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.UserWalletId
@ -33,23 +32,4 @@ data class UserWallet(
val isLocked: Boolean
get() = scanResponse.card.wallets.isEmpty()
}
/**
* !!! Workaround !!!
*
* Calculate same [UserWalletId] for twins instead
*
* TODO: Remove after [REDACTED_JIRA]
* */
fun UserWallet.isTwinnedWith(other: UserWallet): Boolean {
if (!scanResponse.isTangemTwins() || !other.scanResponse.isTangemTwins()) return false
if (scanResponse.secondTwinPublicKey == null || other.scanResponse.secondTwinPublicKey == null) return false
if (other.scanResponse.secondTwinPublicKey == scanResponse.card.wallets.firstOrNull()?.publicKey?.toHexString()) {
return true
}
if (scanResponse.secondTwinPublicKey == other.scanResponse.card.wallets.firstOrNull()?.publicKey?.toHexString()) {
return true
}
return false
}

View file

@ -1,5 +1,7 @@
package com.tangem.tap.domain.model.builders
import com.tangem.common.card.EllipticCurve
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.extensions.toHexString
import com.tangem.common.services.Result
import com.tangem.domain.common.CardDTO
@ -8,7 +10,6 @@ import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.TwinsHelper
import com.tangem.domain.common.util.userWalletId
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.operations.attestation.TangemApi
import com.tangem.tap.domain.model.UserWallet
@ -40,7 +41,16 @@ class UserWalletBuilder(
ProductType.Note -> false
ProductType.Twins -> false
ProductType.SaltPay -> false
ProductType.Wallet -> !card.isStart2Coin
ProductType.Wallet -> when {
card.isStart2Coin -> false
card.firmwareVersion >= FirmwareVersion.MultiWalletAvailable -> true
else -> {
val cardWallets = card.wallets
require(cardWallets.isNotEmpty()) { "Card wallets must not be empty" }
cardWallets.first().curve == EllipticCurve.Secp256k1
}
}
}
fun backupCardsIds(backupCardsIds: Set<String>?) = this.apply {
@ -49,16 +59,20 @@ class UserWalletBuilder(
}
}
suspend fun build(): UserWallet {
suspend fun build(): UserWallet? {
return with(scanResponse) {
UserWallet(
walletId = card.userWalletId,
name = userWalletName,
artworkUrl = loadArtworkUrl(card.cardId, card.cardPublicKey),
cardsInWallet = backupCardsIds.plus(card.cardId),
scanResponse = this,
isMultiCurrency = isMultiCurrency,
)
UserWalletIdBuilder.scanResponse(scanResponse)
.build()
?.let {
UserWallet(
walletId = it,
name = userWalletName,
artworkUrl = loadArtworkUrl(card.cardId, card.cardPublicKey),
cardsInWallet = backupCardsIds.plus(card.cardId),
scanResponse = this,
isMultiCurrency = isMultiCurrency,
)
}
}
}

View file

@ -0,0 +1,70 @@
package com.tangem.tap.domain.model.builders
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.crypto.Secp256k1
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ProductType
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.common.extensions.calculateHmacSha256
import com.tangem.domain.common.util.UserWalletId
class UserWalletIdBuilder private constructor(
private val publicKey: ByteArray?,
private val pairTwinPublicKey: ByteArray? = null,
) {
fun build(): UserWalletId? {
val seed = if (publicKey != null) {
if (pairTwinPublicKey != null) {
Secp256k1.sum(publicKey, pairTwinPublicKey)
} else {
publicKey
}
} else null
return seed?.let {
UserWalletId(value = calculateUserWalletId(it))
}
}
private fun calculateUserWalletId(seed: ByteArray?): ByteArray? {
val message = MESSAGE_FOR_WALLET_ID.toByteArray()
val keyHash = seed?.calculateSha256()
return if (keyHash != null) {
message.calculateHmacSha256(keyHash)
} else null
}
companion object {
private const val MESSAGE_FOR_WALLET_ID = "UserWalletID"
@Throws(IllegalArgumentException::class)
fun card(card: CardDTO): UserWalletIdBuilder {
require(!card.isTangemTwins) {
"For twin cards use scanResponse to ID calculation"
}
return UserWalletIdBuilder(findPublicKey(card.wallets))
}
fun scanResponse(scanResponse: ScanResponse): UserWalletIdBuilder {
return UserWalletIdBuilder(
publicKey = findPublicKey(scanResponse.card.wallets),
pairTwinPublicKey = when (scanResponse.productType) {
ProductType.Twins -> scanResponse.secondTwinPublicKey?.hexToBytes()
ProductType.Note,
ProductType.Wallet,
ProductType.SaltPay,
-> null
},
)
}
private fun findPublicKey(wallets: List<CardDTO.Wallet>): ByteArray? {
return wallets.firstOrNull()
?.publicKey
}
}
}

View file

@ -7,8 +7,8 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.TangemTechService
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.util.userWalletId
import com.tangem.tap.common.AndroidFileReader
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
import com.tangem.tap.domain.tokens.converters.CurrencyConverter
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
import com.tangem.tap.features.demo.DemoHelper
@ -29,7 +29,7 @@ class UserTokensRepository(
// TODO("After adding DI") replace with CoroutineDispatcherProvider
suspend fun getUserTokens(card: CardDTO): List<Currency> = withContext(dispatchers.io) {
val userId = card.userWalletId.stringValue
val userId = getUserWalletId(card) ?: return@withContext emptyList()
if (DemoHelper.isDemoCardId(card.cardId)) {
return@withContext loadTokensOffline(card, userId).ifEmpty(::loadDemoCurrencies)
}
@ -55,17 +55,15 @@ class UserTokensRepository(
// TODO("After adding DI") replace with CoroutineDispatcherProvider
suspend fun saveUserTokens(card: CardDTO, tokens: List<Currency>) = withContext(dispatchers.io) {
val userId = card.userWalletId.stringValue
val userId = getUserWalletId(card) ?: return@withContext
val userTokens = tokens.toUserTokensResponse()
tangemTechApi.saveUserTokens(userId, userTokens)
storageService.saveUserTokens(userId, userTokens)
}
suspend fun loadBlockchainsToDerive(card: CardDTO): List<BlockchainNetwork> = withContext(dispatchers.io) {
val blockchainNetworks = loadTokensOffline(
card = card,
userId = card.userWalletId.stringValue,
).toBlockchainNetworks()
val userId = getUserWalletId(card) ?: return@withContext emptyList()
val blockchainNetworks = loadTokensOffline(card = card, userId = userId).toBlockchainNetworks()
if (DemoHelper.isDemoCardId(card.cardId)) {
return@withContext blockchainNetworks.ifEmpty(loadDemoCurrencies()::toBlockchainNetworks)
@ -109,6 +107,11 @@ class UserTokensRepository(
}
}
private fun getUserWalletId(card: CardDTO): String? {
return UserWalletIdBuilder.card(card).build()
?.stringValue
}
companion object {
private const val GROUP_DEFAULT_VALUE = "none"
private const val SORT_DEFAULT_VALUE = "manual"

View file

@ -19,7 +19,7 @@ interface UserWalletsListManager {
suspend fun selectWallet(userWalletId: UserWalletId): CompletionResult<UserWallet>
/**
* Save user wallet
* 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 to save an
* already saved card
@ -27,6 +27,17 @@ interface UserWalletsListManager {
* */
suspend fun save(userWallet: UserWallet, canOverride: Boolean = false): CompletionResult<Unit>
/**
* Same as [save] but not change selected user wallet ID
* and not terminate with [UserWalletListError.WalletAlreadySaved] if [UserWallet] already saved
*
* Can terminate with [NoSuchElementException] if unable to find [UserWallet] with provided [UserWalletId]
* @param userWalletId update [UserWallet] with that [UserWalletId]
* @param update lambda that receives stored [UserWallet] and returns updated [UserWallet]
* @return [CompletionResult] of operation with updated [UserWallet]
* */
suspend fun update(userWalletId: UserWalletId, update: (UserWallet) -> UserWallet): CompletionResult<UserWallet>
suspend fun delete(userWalletIds: List<UserWalletId>): CompletionResult<Unit>
suspend fun clear(): CompletionResult<Unit>

View file

@ -3,7 +3,6 @@ package com.tangem.tap.domain.userWalletList.implementation
import com.tangem.common.*
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.model.isTwinnedWith
import com.tangem.tap.domain.userWalletList.UserWalletListError
import com.tangem.tap.domain.userWalletList.UserWalletsListManager
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
@ -11,6 +10,7 @@ import com.tangem.tap.domain.userWalletList.repository.SelectedUserWalletReposit
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysRepository
import com.tangem.tap.domain.userWalletList.repository.UserWalletsPublicInformationRepository
import com.tangem.tap.domain.userWalletList.repository.UserWalletsSensitiveInformationRepository
import com.tangem.tap.domain.userWalletList.utils.encryptionKey
import com.tangem.tap.domain.userWalletList.utils.toUserWallets
import com.tangem.tap.domain.userWalletList.utils.updateWith
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -51,7 +51,7 @@ internal class BiometricUserWalletsListManager(
get() = state.value.isLocked
override val hasSavedUserWallets: Boolean
get() = publicInformationRepository.isNotEmpty()
get() = keysRepository.hasSavedEncryptionKeys()
override suspend fun unlockWithBiometry(): CompletionResult<UserWallet?> {
return unlockWithBiometryInternal()
@ -85,22 +85,35 @@ internal class BiometricUserWalletsListManager(
override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> {
return if (canOverride) {
saveInternal(userWallet)
saveInternal(userWallet, changeSelectedUserWallet = true)
} else {
val isWalletSaved = state.value.userWallets
.any {
// Workaround, check [UserWallet.isTwinnedWith]
it.cardsInWallet.contains(userWallet.cardId) || it.isTwinnedWith(userWallet)
it.walletId == userWallet.walletId || it.cardsInWallet.contains(userWallet.cardId)
}
if (isWalletSaved) {
CompletionResult.Failure(UserWalletListError.WalletAlreadySaved)
} else {
saveInternal(userWallet)
saveInternal(userWallet, changeSelectedUserWallet = true)
}
}
}
override suspend fun update(
userWalletId: UserWalletId,
update: (UserWallet) -> UserWallet,
): CompletionResult<UserWallet> {
return get(userWalletId)
.map { storedUserWallet ->
update(storedUserWallet)
}
.flatMap { updatedUserWallet ->
saveInternal(updatedUserWallet, changeSelectedUserWallet = false)
.map { updatedUserWallet }
}
}
override suspend fun delete(userWalletIds: List<UserWalletId>): CompletionResult<Unit> {
if (userWalletIds.isEmpty()) {
return CompletionResult.Success(Unit)
@ -113,9 +126,12 @@ internal class BiometricUserWalletsListManager(
.flatMap { keysRepository.delete(userWalletIds) }
.map {
state.update { prevState ->
val newUserWallets = prevState.userWallets.filter { it.walletId !in userWalletIds }
prevState.copy(
encryptionKeys = prevState.encryptionKeys.filter { it.walletId !in userWalletIds },
userWallets = prevState.userWallets.filter { it.walletId !in userWalletIds },
userWallets = newUserWallets,
isLocked = newUserWallets.any { it.isLocked },
)
}
}
@ -137,27 +153,23 @@ internal class BiometricUserWalletsListManager(
}
}
private suspend fun saveInternal(userWallet: UserWallet): CompletionResult<Unit> {
val newEncryptionKeys = state.value.encryptionKeys
.plus(UserWalletEncryptionKey(userWallet))
.distinctBy { it.walletId }
return keysRepository.store(newEncryptionKeys)
.doOnSuccess {
state.update { prevState ->
prevState.copy(
encryptionKeys = newEncryptionKeys,
selectedUserWalletId = userWallet.walletId,
)
}
}
private suspend fun saveInternal(
userWallet: UserWallet,
changeSelectedUserWallet: Boolean,
): CompletionResult<Unit> {
return saveEncryptionKeyIfNotNull(userWallet)
.flatMap { sensitiveInformationRepository.save(userWallet, encryptionKey = it) }
.flatMap { publicInformationRepository.save(userWallet) }
.flatMap { sensitiveInformationRepository.save(userWallet) }
.map { selectedUserWalletRepository.set(userWallet.walletId) }
.flatMap { loadModels() }
.doOnSuccess {
state.update { prevState ->
prevState.copy(
selectedUserWalletId = if (changeSelectedUserWallet) {
userWallet.walletId
} else {
prevState.selectedUserWalletId
},
isLocked = prevState.userWallets.any { it.isLocked },
)
}
@ -183,6 +195,27 @@ internal class BiometricUserWalletsListManager(
}
}
private suspend fun saveEncryptionKeyIfNotNull(userWallet: UserWallet): CompletionResult<ByteArray?> {
val encryptionKey = userWallet.scanResponse.card.encryptionKey
?.let { UserWalletEncryptionKey(userWallet.walletId, it) }
return if (encryptionKey != null) {
keysRepository.save(encryptionKey)
.doOnSuccess {
state.update { prevState ->
prevState.copy(
encryptionKeys = prevState.encryptionKeys
.plus(encryptionKey)
.distinctBy { it.walletId },
)
}
}
.map { encryptionKey.encryptionKey }
} else {
CompletionResult.Success(data = null)
}
}
private suspend fun loadModels(): CompletionResult<Unit> {
return getSavedUserWallets()
.map { userWallets ->

View file

@ -1,56 +0,0 @@
package com.tangem.tap.domain.userWalletList.implementation
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.UserWalletsListManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class DummyUserWalletsListManager : UserWalletsListManager {
override val userWallets: Flow<List<UserWallet>>
get() = flowOf(emptyList())
override val selectedUserWallet: Flow<UserWallet>
get() = flowOf()
override val selectedUserWalletSync: UserWallet?
get() = null
override val isLocked: Flow<Boolean>
get() = flowOf(true)
override val isLockedSync: Boolean
get() = true
override val hasSavedUserWallets: Boolean
get() = false
override suspend fun unlockWithBiometry(): CompletionResult<UserWallet?> {
return CompletionResult.Success(null)
}
override fun lock() {
/* no-op */
}
override suspend fun selectWallet(userWalletId: UserWalletId): CompletionResult<UserWallet> {
return catching {
error("Not implemented")
}
}
override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> {
return CompletionResult.Success(Unit)
}
override suspend fun delete(userWalletIds: List<UserWalletId>): CompletionResult<Unit> {
return CompletionResult.Success(Unit)
}
override suspend fun clear(): CompletionResult<Unit> {
return CompletionResult.Success(Unit)
}
override suspend fun get(userWalletId: UserWalletId): CompletionResult<UserWallet> {
return catching {
error("Not implemented")
}
}
}

View file

@ -2,19 +2,12 @@ package com.tangem.tap.domain.userWalletList.model
import com.squareup.moshi.JsonClass
import com.tangem.domain.common.util.UserWalletId
import com.tangem.domain.common.util.encryptionKey
import com.tangem.tap.domain.model.UserWallet
@JsonClass(generateAdapter = true)
internal data class UserWalletEncryptionKey(
val walletId: UserWalletId,
val encryptionKey: ByteArray,
) {
constructor(userWallet: UserWallet) : this(
walletId = userWallet.walletId,
encryptionKey = userWallet.scanResponse.card.encryptionKey,
)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is UserWalletEncryptionKey) return false

View file

@ -14,11 +14,11 @@ internal interface UserWalletsKeysRepository {
suspend fun getAll(): CompletionResult<List<UserWalletEncryptionKey>>
/**
* Store the encryption keys for user wallets. Biometric authentication not required
* @param encryptionKeys List of encryption keys for user wallets
* Save the encryption key for user wallet. Biometric authentication not required
* @param encryptionKey [UserWalletEncryptionKey] to save
* @return [CompletionResult] of operation
* */
suspend fun store(encryptionKeys: List<UserWalletEncryptionKey>): CompletionResult<Unit>
suspend fun save(encryptionKey: UserWalletEncryptionKey): CompletionResult<Unit>
/**
* Delete encryption keys for user wallets. Biometric authentication not required
@ -32,4 +32,10 @@ internal interface UserWalletsKeysRepository {
* @return [CompletionResult] of operation
* */
suspend fun clear(): CompletionResult<Unit>
/**
* Determine if the user has saved user wallets
* @return [Boolean] true if user has saved wallets
* */
fun hasSavedEncryptionKeys(): Boolean
}

View file

@ -12,6 +12,4 @@ internal interface UserWalletsPublicInformationRepository {
suspend fun delete(walletIds: List<UserWalletId>): CompletionResult<Unit>
suspend fun clear(): CompletionResult<Unit>
fun isNotEmpty(): Boolean
}

View file

@ -7,7 +7,7 @@ import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation
internal interface UserWalletsSensitiveInformationRepository {
suspend fun save(userWallet: UserWallet): CompletionResult<Unit>
suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult<Unit>
suspend fun getAll(
encryptionKeys: List<UserWalletEncryptionKey>,
): CompletionResult<Map<UserWalletId, UserWalletSensitiveInformation>>

View file

@ -17,6 +17,7 @@ import com.tangem.tap.domain.userWalletList.UserWalletListError
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
internal class BiometricUserWalletsKeysRepository(
@ -44,10 +45,9 @@ internal class BiometricUserWalletsKeysRepository(
}
}
override suspend fun store(encryptionKeys: List<UserWalletEncryptionKey>): CompletionResult<Unit> {
override suspend fun save(encryptionKey: UserWalletEncryptionKey): CompletionResult<Unit> {
return withContext(Dispatchers.IO) {
encryptionKeys.map { storeEncryptionKey(it) }
.fold()
storeEncryptionKey(encryptionKey)
.mapFailure { error ->
UserWalletListError.SaveEncryptionKeysError(error.cause ?: error)
}
@ -77,6 +77,12 @@ internal class BiometricUserWalletsKeysRepository(
}
}
override fun hasSavedEncryptionKeys(): Boolean {
return runBlocking {
getUserWalletsIds().isNotEmpty()
}
}
private suspend fun getAllInternal(): CompletionResult<List<UserWalletEncryptionKey>> {
return getUserWalletsIds()
.map { userWalletId ->

View file

@ -72,10 +72,6 @@ internal class DefaultUserWalletsPublicInformationRepository(
}
}
override fun isNotEmpty(): Boolean {
return secureStorage.get(StorageKey.UserWalletPublicInformation.name)?.isNotEmpty() == true
}
@JvmName("saveWithPublicInformation")
private suspend fun save(
publicInformation: List<UserWalletPublicInformation>,

View file

@ -9,13 +9,13 @@ import com.tangem.common.catching
import com.tangem.common.mapFailure
import com.tangem.common.services.secure.SecureStorage
import com.tangem.domain.common.util.UserWalletId
import com.tangem.domain.common.util.encryptionKey
import com.tangem.tap.common.extensions.filterNotNull
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.userWalletList.UserWalletListError
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation
import com.tangem.tap.domain.userWalletList.repository.UserWalletsSensitiveInformationRepository
import com.tangem.tap.domain.userWalletList.utils.encryptionKey
import com.tangem.tap.domain.userWalletList.utils.sensitiveInformation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -38,11 +38,12 @@ internal class DefaultUserWalletsSensitiveInformationRepository(
Cipher.getInstance("$algorithm/$blockMode/$encryptionPadding")
}
override suspend fun save(userWallet: UserWallet): CompletionResult<Unit> {
override suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult<Unit> {
if (encryptionKey == null) return CompletionResult.Success(Unit) // Encryption key is null, do nothing
return catching {
val encryptedSensitiveInformation = userWallet.sensitiveInformation
.encode()
.encryptAndStoreIv(userWallet.walletId.stringValue, userWallet.scanResponse.card.encryptionKey)
.encryptAndStoreIv(userWallet.walletId.stringValue, encryptionKey)
getAllEncrypted().toMutableMap()
.apply { set(userWallet.walletId.stringValue, encryptedSensitiveInformation) }

View file

@ -0,0 +1,22 @@
package com.tangem.tap.domain.userWalletList.utils
import com.tangem.common.extensions.calculateSha256
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.extensions.calculateHmacSha256
internal val CardDTO.encryptionKey: ByteArray?
get() = findPublicKey(wallets)?.let { calculateEncryptionKey(it) }
private fun calculateEncryptionKey(publicKey: ByteArray): ByteArray {
val message = MESSAGE_FOR_ENCRYPTION_KEY.toByteArray()
val keyHash = publicKey.calculateSha256()
return message.calculateHmacSha256(keyHash)
}
private fun findPublicKey(wallets: List<CardDTO.Wallet>): ByteArray? {
return wallets.firstOrNull()
?.publicKey
}
private const val MESSAGE_FOR_ENCRYPTION_KEY = "UserWalletEncryptionKey"

View file

@ -20,6 +20,7 @@ import com.tangem.tap.domain.walletStores.repository.WalletStoresRepository
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.features.wallet.models.toBlockchainNetworks
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
internal class DefaultWalletCurrenciesManager(
@ -64,7 +65,15 @@ internal class DefaultWalletCurrenciesManager(
saveUserCurrencies(card, newCurrencies)
}
.flatMap {
walletAmountsRepository.updateAmountsForUserWallet(
val updatedBlockchains = updatedBlockchainNetworks
.map { it.blockchain }
val updatedWalletStores = walletStoresRepository.get(userWallet.walletId)
.firstOrNull()
?.filter { it.blockchain in updatedBlockchains }
?: return@flatMap CompletionResult.Success(Unit)
walletAmountsRepository.updateAmountsForWalletStores(
walletStores = updatedWalletStores,
userWallet = userWallet,
fiatCurrency = appCurrencyProvider(),
)

View file

@ -29,6 +29,12 @@ interface WalletAmountsRepository {
fiatCurrency: FiatCurrency,
): CompletionResult<Unit>
suspend fun updateAmountsForWalletStores(
walletStores: List<WalletStoreModel>,
userWallet: UserWallet,
fiatCurrency: FiatCurrency,
): CompletionResult<Unit>
/**
* Fetch wallet amounts and fiat rates then update [com.tangem.tap.domain.walletStores.storage.WalletStoresStorage]
* and [com.tangem.tap.domain.walletStores.storage.WalletManagerStorage] with new data

View file

@ -42,7 +42,8 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
@ -62,7 +63,7 @@ internal class DefaultWalletAmountsRepository(
else withContext(Dispatchers.Default) {
awaitAll(
async { fetchAmountsForUserWallets(userWallets) },
async { fetchFiatRates(userWallets, fiatCurrency) },
async { fetchFiatRates(userWallets, walletStores = null, fiatCurrency) },
)
.fold()
}
@ -75,46 +76,40 @@ internal class DefaultWalletAmountsRepository(
return updateAmountsForUserWallets(listOf(userWallet), fiatCurrency)
}
override suspend fun updateAmountsForWalletStores(
walletStores: List<WalletStoreModel>,
userWallet: UserWallet,
fiatCurrency: FiatCurrency,
): CompletionResult<Unit> {
return if (walletStores.isEmpty()) CompletionResult.Success(Unit)
else withContext(Dispatchers.Default) {
val userWalletId = userWallet.walletId
val scanResponse = userWallet.scanResponse
awaitAll(
async { fetchAmountForWalletStores(userWalletId, scanResponse, walletStores) },
async { fetchFiatRates(listOf(userWallet), walletStores, fiatCurrency) },
)
.fold()
}
}
override suspend fun updateAmountsForWalletStore(
walletStore: WalletStoreModel,
userWallet: UserWallet,
fiatCurrency: FiatCurrency,
): CompletionResult<Unit> = withContext(Dispatchers.Default) {
val walletId = userWallet.walletId
val scanResponse = userWallet.scanResponse
awaitAll(
async {
// TODO: Find wallet manager via [com.tangem.tap.domain.walletStores.repository.WalletManagersRepository]
val walletManager = walletStore.walletManager
fetchAmountsForWalletStore(walletId, scanResponse, walletStore, walletManager)
},
async { fetchFiatRates(listOf(userWallet), fiatCurrency) },
)
.fold()
}
private suspend fun fetchAmountsForUserWallets(
userWallets: List<UserWallet>,
): CompletionResult<Unit> = withContext(Dispatchers.Default) {
userWallets.map { async { fetchAmountsForUserWallet(it) } }
.awaitAll()
.fold()
): CompletionResult<Unit> {
return updateAmountsForWalletStores(listOf(walletStore), userWallet, fiatCurrency)
}
private suspend fun fetchFiatRates(
userWallets: List<UserWallet>,
walletStores: List<WalletStoreModel>?,
fiatCurrency: FiatCurrency,
): CompletionResult<Unit> {
val walletsIds = userWallets.map { it.walletId }
val walletStores = walletsIds
.flatMap {
walletStoresStorage.getAll()
.first()
.getOrElse(it) { emptyList() }
}
val walletStoresInternal = walletStores ?: getWalletStores(userWallets)
val currencies = walletStores
val currencies = walletStoresInternal
.asSequence()
.flatMap { it.walletsData }
.map { it.currency }
@ -124,7 +119,7 @@ internal class DefaultWalletAmountsRepository(
return withContext(dispatchers.io) {
runCatching { tangemTechApi.getRates(fiatCurrency.code.lowercase(), coinsIds.joinToString(",")) }
.onSuccess {
updateWalletStoresWithFiatRates(walletStores = walletStores, fiatRates = it.rates)
updateWalletStoresWithFiatRates(walletStores = walletStoresInternal, fiatRates = it.rates)
return@withContext CompletionResult.Success(Unit)
}
.onFailure {
@ -137,7 +132,6 @@ internal class DefaultWalletAmountsRepository(
error,
"""
Unable to fetch fiat rates
|- User wallets ids: $walletsIds
|- Coins ids: $coinsIds
""".trimIndent(),
)
@ -149,20 +143,34 @@ internal class DefaultWalletAmountsRepository(
}
}
private suspend fun fetchAmountsForUserWallets(
userWallets: List<UserWallet>,
): CompletionResult<Unit> = withContext(Dispatchers.Default) {
userWallets.map { async { fetchAmountsForUserWallet(it) } }
.awaitAll()
.fold()
}
private suspend fun fetchAmountsForUserWallet(
userWallet: UserWallet,
): CompletionResult<Unit> = withContext(Dispatchers.Default) {
val walletId = userWallet.walletId
val userWalletId = userWallet.walletId
val scanResponse = userWallet.scanResponse
val walletStores = walletStoresStorage.getAll()
.first()
.getOrElse(walletId) { emptyList() }
val walletStores = getWalletStores(listOf(userWallet))
fetchAmountForWalletStores(userWalletId, scanResponse, walletStores)
}
private suspend fun fetchAmountForWalletStores(
userWalletId: UserWalletId,
scanResponse: ScanResponse,
walletStores: List<WalletStoreModel>,
): CompletionResult<Unit> = coroutineScope {
walletStores.map { walletStore ->
async {
// TODO: Find wallet manager via [com.tangem.tap.domain.walletStores.repository.WalletManagersRepository]
val walletManager = walletStore.walletManager
fetchAmountsForWalletStore(walletId, scanResponse, walletStore, walletManager)
fetchAmountsForWalletStore(userWalletId, scanResponse, walletStore, walletManager)
}
}
.awaitAll()
@ -170,7 +178,7 @@ internal class DefaultWalletAmountsRepository(
}
private suspend fun fetchAmountsForWalletStore(
walletId: UserWalletId,
userWalletId: UserWalletId,
scanResponse: ScanResponse,
walletStore: WalletStoreModel,
walletManager: WalletManager?,
@ -180,11 +188,15 @@ internal class DefaultWalletAmountsRepository(
}
return when {
hasMissedDerivations -> updateWalletStoreWithMissedDerivation(walletStore)
walletManager == null -> updateWalletStoreWithUnreachable(walletStore)
hasMissedDerivations -> {
updateWalletStoreWithMissedDerivation(walletStore)
}
walletManager == null -> {
updateWalletStoreWithUnreachable(walletStore)
}
else -> {
withInternetConnection { walletManager.update() }
.map { updateWalletManagerWithAmounts(walletId, walletManager) }
.map { updateWalletManagerWithAmounts(userWalletId, walletManager) }
.flatMap {
updateWalletStoreWithAmounts(
walletStore = walletStore,
@ -242,35 +254,6 @@ internal class DefaultWalletAmountsRepository(
return CompletionResult.Success(Unit)
}
private suspend inline fun withInternetConnection(crossinline block: suspend () -> Unit): CompletionResult<Unit> {
return if (!NetworkConnectivity.getInstance().isOnlineOrConnecting()) {
val error = WalletStoresError.NoInternetConnection
Timber.e(error)
CompletionResult.Failure(error)
} else withContext(Dispatchers.IO) {
catching { block() }
}
}
private suspend fun updateWalletManagerWithAmounts(
walletId: UserWalletId,
walletManager: WalletManager,
) = withContext(Dispatchers.Default) {
walletManagersStorage.update { prevManagers ->
val newManagersForUserWallet = prevManagers[walletId].orEmpty()
.toMutableList()
.apply {
replaceByOrAdd(walletManager) {
it.wallet.blockchain == it.wallet.blockchain
}
}
prevManagers.apply {
set(walletId, newManagersForUserWallet)
}
}
}
private suspend fun updateWalletStoreWithError(
walletStore: WalletStoreModel,
wallet: Wallet,
@ -419,4 +402,44 @@ internal class DefaultWalletAmountsRepository(
}
}
}
private suspend inline fun withInternetConnection(crossinline block: suspend () -> Unit): CompletionResult<Unit> {
return if (!NetworkConnectivity.getInstance().isOnlineOrConnecting()) {
val error = WalletStoresError.NoInternetConnection
Timber.e(error)
CompletionResult.Failure(error)
} else withContext(Dispatchers.IO) {
catching { block() }
}
}
private suspend fun updateWalletManagerWithAmounts(
userWalletId: UserWalletId,
walletManager: WalletManager,
) = withContext(Dispatchers.Default) {
walletManagersStorage.update { prevManagers ->
val newManagersForUserWallet = prevManagers[userWalletId].orEmpty()
.toMutableList()
.apply {
replaceByOrAdd(walletManager) {
it.wallet.blockchain == it.wallet.blockchain
}
}
prevManagers.apply {
set(userWalletId, newManagersForUserWallet)
}
}
}
private suspend fun getWalletStores(userWallets: List<UserWallet>): List<WalletStoreModel> {
return userWallets
.map { it.walletId }
.flatMap { userWalletId ->
walletStoresStorage.getAll()
.firstOrNull()
?.get(userWalletId)
.orEmpty()
}
}
}

View file

@ -106,11 +106,7 @@ internal class DefaultWalletManagersRepository(
override suspend fun delete(userWalletIds: List<UserWalletId>): CompletionResult<Unit> = catching {
walletManagersStorage.update { prevManagers ->
prevManagers.apply {
userWalletIds.forEach { userWalletId ->
remove(userWalletId)
}
}
prevManagers.filterKeys { it !in userWalletIds } as HashMap<UserWalletId, List<WalletManager>>
}
}

View file

@ -62,9 +62,11 @@ internal fun WalletStoreModel.updateWithSelf(
): WalletStoreModel {
val oldStore = this
return oldStore.copy(
walletManager = newWalletStore.walletManager,
walletRent = newWalletStore.walletRent,
derivationPath = newWalletStore.derivationPath,
walletsData = oldStore.walletsData.updateWithSelf(newWalletStore.walletsData),
walletRent = newWalletStore.walletRent,
blockchainNetwork = newWalletStore.blockchainNetwork,
walletManager = newWalletStore.walletManager,
)
}

View file

@ -4,10 +4,8 @@ import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.extensions.toHexString
import com.tangem.common.flatMap
import com.tangem.domain.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.common.util.userWalletId
import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
@ -20,6 +18,7 @@ import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.model.builders.UserWalletBuilder
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.onboarding.products.twins.redux.CreateTwinWalletMode
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsAction
@ -27,6 +26,7 @@ import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.tap.userTokensRepository
import com.tangem.tap.userWalletsListManager
import com.tangem.tap.walletStoresManager
import com.tangem.wallet.R
@ -79,20 +79,16 @@ class DetailsMiddleware {
}
DetailsAction.ScanCard -> {
scope.launch {
tangemSdkManager.scanCard(allowRequestAccessCodeFromRepository = true)
.doOnSuccess { card ->
val isSameWallet = state.scanResponse?.card?.userWalletId
?.equals(card.userWalletId)
?: false
tangemSdkManager.scanProduct(userTokensRepository)
.doOnSuccess { scanResponse ->
val currentUserWalletId = state.scanResponse
?.let { UserWalletIdBuilder.scanResponse(it).build() }
val scannedUserWalletId = UserWalletIdBuilder.scanResponse(scanResponse)
.build()
val isSameWallet = currentUserWalletId == scannedUserWalletId
// !!! Workaround !!!
// TODO: Remove after [REDACTED_JIRA]
val isTwinned = card.wallets.firstOrNull()?.publicKey?.toHexString()
?.equals(state.scanResponse?.secondTwinPublicKey)
?: false
if (isSameWallet || isTwinned) {
store.dispatchOnMain(DetailsAction.PrepareCardSettingsData(card))
if (isSameWallet) {
store.dispatchOnMain(DetailsAction.PrepareCardSettingsData(scanResponse.card))
} else {
store.dispatchDialogShow(
AppDialog.SimpleOkDialogRes(
@ -122,8 +118,10 @@ class DetailsMiddleware {
is DetailsAction.ResetToFactory.Proceed -> {
val card = store.state.detailsState.cardSettingsState?.card ?: return
scope.launch {
val userWalletId = UserWalletIdBuilder.card(card).build()
tangemSdkManager.resetToFactorySettings(card.cardId)
.flatMap { userWalletsListManager.delete(listOf(card.userWalletId)) }
.flatMap { userWalletsListManager.delete(listOfNotNull(userWalletId)) }
.flatMap { tangemSdkManager.deleteSavedUserCodes(setOf(card.cardId)) }
.doOnSuccess {
Analytics.send(Settings.CardSettings.FactoryResetFinished())
@ -275,12 +273,9 @@ class DetailsMiddleware {
private suspend fun saveCurrentWallet(state: DetailsState) {
val scanResponse = state.scanResponse ?: return
val userWallet = UserWalletBuilder(scanResponse).build()
val userWallet = UserWalletBuilder(scanResponse).build() ?: return
userWalletsListManager.save(userWallet)
.doOnFailure { error ->
Timber.e(error, "Wallet saving failed")
}
.doOnSuccess {
Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.On))
@ -296,6 +291,9 @@ class DetailsMiddleware {
store.onUserWalletSelected(userWallet)
}
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")
}
}
private suspend fun deleteSavedWallets() {
@ -315,6 +313,9 @@ class DetailsMiddleware {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Home))
}
.doOnFailure { error ->
Timber.e(error, "Unable to delete saved wallets")
}
}
private fun saveAccessCodes(state: DetailsState) {
@ -353,6 +354,9 @@ class DetailsMiddleware {
)
}
}
.doOnFailure { error ->
Timber.e(error, "Unable to delete saved access codes")
}
}
}
}

View file

@ -12,7 +12,6 @@ import com.tangem.tap.domain.extensions.signedHashesCount
import com.tangem.tap.preferencesStorage
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.tap.userWalletsListManager
import org.rekotlin.Action
import java.util.*
@ -58,7 +57,7 @@ private fun handlePrepareScreen(
createBackupAllowed = action.scanResponse.card.backupStatus == CardDTO.BackupStatus.NoBackup,
appCurrency = store.state.globalState.appCurrency,
isBiometricsAvailable = tangemSdkManager.canUseBiometry,
saveWallets = userWalletsListManager.hasSavedUserWallets,
saveWallets = preferencesStorage.shouldSaveUserWallets,
saveAccessCodes = preferencesStorage.shouldSaveAccessCodes,
)
}

View file

@ -8,6 +8,7 @@ import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.details.redux.CardSettingsState
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.wallet.R
import org.rekotlin.Store
class CardSettingsViewModel(private val store: Store<AppState>) {

View file

@ -20,6 +20,7 @@ import androidx.compose.material.IconToggleButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@ -37,6 +38,7 @@ fun ResetCardScreen(state: ResetCardScreenState, onBackPressed: () -> Unit) {
SettingsScreensScaffold(
content = { ResetCardView(state = state) },
onBackClick = onBackPressed,
backgroundColor = Color.Transparent,
)
}

View file

@ -93,7 +93,7 @@ private fun readCard() = scope.launch {
onSuccess = { scanResponse ->
scope.launch {
if (preferencesStorage.shouldSaveUserWallets) {
val userWallet = UserWalletBuilder(scanResponse).build()
val userWallet = UserWalletBuilder(scanResponse).build() ?: return@launch
userWalletsListManager.save(userWallet)
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")

View file

@ -11,8 +11,8 @@ import com.tangem.tap.common.extensions.copyToClipboard
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.common.extensions.dispatchShare
import com.tangem.tap.common.extensions.dispatchToastNotification
import com.tangem.tap.common.extensions.getString
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.features.wallet.redux.AddressData
import com.tangem.tap.store
import com.tangem.wallet.R
@ -62,26 +62,12 @@ class AddressInfoBottomSheetDialog(
Analytics.send(Token.Recieve.ButtonShareAddress())
store.dispatchShare(data.shareUrl)
}
tvReceiveMessage.text = getQRReceiveMessage(tvReceiveMessage.context, stateDialog.currency)
}
}
fun getQRReceiveMessage(context: Context, currency: Currency): String {
return when (currency) {
is Currency.Blockchain -> {
context.getString(
R.string.address_qr_code_message_format,
currency.blockchain.fullName,
currency.currencySymbol,
)
}
is Currency.Token -> {
context.getString(
R.string.address_qr_code_message_token_format,
currency.token.name,
currency.currencySymbol,
currency.blockchain.fullName,
)
}
val blockchain = stateDialog.currency.blockchain
tvReceiveMessage.text = tvReceiveMessage.getString(
id = R.string.address_qr_code_message_format,
blockchain.fullName,
blockchain.currency,
blockchain.fullName,
)
}
}

View file

@ -70,7 +70,10 @@ class OnboardingHelper {
// then open save wallet screen
tangemSdkManager.canUseBiometry &&
preferencesStorage.shouldShowSaveUserWalletScreen -> scope.launch {
store.onCardScanned(scanResponse)
delay(timeMillis = 1_200)
store.dispatchOnMain(
SaveWalletAction.ProvideBackupInfo(
scanResponse = scanResponse,

View file

@ -6,7 +6,6 @@ import com.tangem.common.CompletionResult
import com.tangem.common.extensions.guard
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.common.util.userWalletId
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.events.AnalyticsParam
@ -25,6 +24,7 @@ import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.extensions.makePrimaryWalletManager
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
import com.tangem.tap.domain.twins.TwinCardsManager
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.features.wallet.models.Currency
@ -156,7 +156,7 @@ private fun handle(action: Action, dispatch: DispatchFunction) {
TwinCardsStep.CreateFirstWallet -> {
scope.launch {
userWalletsListManager.delete(
listOf(getScanResponse().card.userWalletId),
listOfNotNull(UserWalletIdBuilder.scanResponse(getScanResponse()).build()),
)
}
}

View file

@ -88,7 +88,7 @@ internal class SaveWalletMiddleware {
scope.launch {
val userWallet = UserWalletBuilder(scanResponse)
.backupCardsIds(state.backupInfo?.backupCardsIds)
.build()
.build() ?: return@launch
val isFirstSavedWallet = !userWalletsListManager.hasSavedUserWallets

View file

@ -149,7 +149,7 @@ class TokensMiddleware {
&& blockchainsToAdd.isEmpty() && blockchainsToRemove.isEmpty()
) {
store.dispatchDebugErrorNotification("Nothing to save")
store.dispatch(NavigationAction.PopBackTo())
store.dispatchOnMain(NavigationAction.PopBackTo())
return@launch
}
@ -281,13 +281,16 @@ class TokensMiddleware {
) {
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync
if (selectedUserWallet != null) {
val updatedUserWallet = selectedUserWallet.copy(
scanResponse = scanResponse,
)
scope.launch {
userWalletsListManager.save(updatedUserWallet, canOverride = true)
.flatMap {
userWalletsListManager.update(
userWalletId = selectedUserWallet.walletId,
update = { userWallet ->
userWallet.copy(
scanResponse = scanResponse,
)
},
)
.flatMap { updatedUserWallet ->
walletCurrenciesManager.addCurrencies(
userWallet = updatedUserWallet,
currenciesToAdd = currencyList,

View file

@ -12,6 +12,7 @@ import com.tangem.tap.common.analytics.events.MainScreen
import com.tangem.tap.common.analytics.events.Token.ButtonRemoveToken
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchErrorNotification
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.safeUpdate
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.global.GlobalState
@ -180,21 +181,25 @@ class MultiWalletMiddleware {
}
private fun scanAndUpdateCard(
selectedWallet: UserWallet,
selectedUserWallet: UserWallet,
state: WalletState?,
) = scope.launch {
) = scope.launch(Dispatchers.Default) {
Analytics.send(MainScreen.CardWasScanned())
ScanCardProcessor.scan(
cardId = selectedWallet.cardId,
cardId = selectedUserWallet.cardId,
additionalBlockchainsToDerive = state?.missingDerivations?.map { it.blockchain },
) { scanResponse ->
val userWallet = selectedWallet.copy(
scanResponse = scanResponse,
userWalletsListManager.update(
userWalletId = selectedUserWallet.walletId,
update = { userWallet ->
userWallet.copy(
scanResponse = scanResponse,
)
},
)
userWalletsListManager.save(userWallet, canOverride = true)
.doOnSuccess {
store.state.globalState.tapWalletManager.loadData(userWallet, refresh = true)
.doOnSuccess { updatedUserWallet ->
store.dispatchOnMain(WalletAction.MultiWallet.AddMissingDerivations(emptyList()))
store.state.globalState.tapWalletManager.loadData(updatedUserWallet, refresh = true)
}
}
}

View file

@ -306,7 +306,7 @@ class WalletMiddleware {
val reduxWalletStores = wallStores.mapToReduxModels(state.isMultiwalletAllowed)
store.dispatchOnMain(
WalletAction.WalletStoresChanged.UpdateWalletStores(
reduxWalletStores = reduxWalletStores.toList(),
reduxWalletStores = reduxWalletStores,
),
)
}

View file

@ -36,7 +36,7 @@ class MultiWalletReducer {
fun reduce(action: WalletAction.MultiWallet, state: WalletState): WalletState {
return when (action) {
is WalletAction.MultiWallet.AddBlockchains -> {
val walletStores: List<WalletStore> = action.blockchains.mapNotNull { blockchain ->
val walletStores: List<WalletStore> = action.blockchains.map { blockchain ->
val walletManager = action.walletManagers.firstOrNull {
it.wallet.blockchain == blockchain.blockchain &&
(it.wallet.publicKey.derivationPath?.rawPath == blockchain.derivationPath)
@ -70,10 +70,10 @@ class MultiWalletReducer {
)
}
val selectedCurrency = if (!state.isMultiwalletAllowed) {
walletStores.firstOrNull()?.walletsData?.firstOrNull()?.currency
} else {
val selectedCurrency = if (state.isMultiwalletAllowed) {
state.selectedCurrency
} else {
walletStores.firstOrNull()?.walletsData?.firstOrNull()?.currency
}
state.copy(
walletsStores = walletStores,

View file

@ -20,7 +20,6 @@ import com.tangem.tap.domain.extensions.isMultiwalletAllowed
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.features.wallet.models.TotalBalance
import com.tangem.tap.features.wallet.models.WalletRent
import com.tangem.tap.features.wallet.redux.AddressData
import com.tangem.tap.features.wallet.redux.Artwork
@ -36,7 +35,6 @@ import com.tangem.tap.features.wallet.redux.replaceSomeWalletsData
import com.tangem.tap.features.wallet.ui.BalanceStatus
import com.tangem.tap.features.wallet.ui.BalanceWidgetData
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.store
import org.rekotlin.Action
import timber.log.Timber
import java.math.BigDecimal
@ -344,8 +342,9 @@ private fun internalReduce(action: Action, state: AppState, appStateHolder: AppS
card.settings.isBackupAllowed &&
card.backupStatus == CardDTO.BackupStatus.NoBackup,
walletCardsCount = card.findCardsCount(),
walletsStores = newState.walletsStores,
totalBalance = if (isMultiCurrency) {
TotalBalance(ProgressState.Loading, BigDecimal.ZERO, store.state.globalState.appCurrency)
newState.totalBalance
} else {
null
},
@ -362,13 +361,13 @@ private fun internalReduce(action: Action, state: AppState, appStateHolder: AppS
)
}
is WalletAction.LoadData.Success -> {
val selectedCurrency = if (!newState.isMultiwalletAllowed) {
val selectedCurrency = if (newState.isMultiwalletAllowed) {
newState.selectedCurrency
} else {
newState.walletsStores.firstOrNull()
?.walletsData
?.firstOrNull()
?.currency
} else {
newState.selectedWalletData?.currency
}
newState = newState.copy(

View file

@ -36,7 +36,6 @@ import com.tangem.tap.common.extensions.toQrCode
import com.tangem.tap.common.recyclerView.SpaceItemDecoration
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
import com.tangem.tap.features.onboarding.getQRReceiveMessage
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.features.wallet.models.PendingTransaction
import com.tangem.tap.features.wallet.redux.ErrorType
@ -132,12 +131,8 @@ class WalletDetailsFragment : Fragment(R.layout.fragment_wallet_details),
override fun onStop() {
super.onStop()
store.unsubscribe(this)
}
override fun onDestroy() {
walletDataWatcher.clear()
walletStateWatcher.clear()
super.onDestroy()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -328,8 +323,21 @@ class WalletDetailsFragment : Fragment(R.layout.fragment_wallet_details),
)
}
ivQrCode.setImageBitmap(state.walletAddresses.selectedAddress.shareUrl.toQrCode())
tvReceiveMessage.text =
getQRReceiveMessage(tvReceiveMessage.context, state.currency)
tvReceiveMessage.text = when (val currency = state.currency) {
is Currency.Blockchain -> tvReceiveMessage.getString(
id = R.string.address_qr_code_message_format,
currency.blockchain.fullName,
currency.currencySymbol,
currency.blockchain.fullName,
)
is Currency.Token -> tvReceiveMessage.getString(
id = R.string.address_qr_code_message_format,
currency.token.name,
currency.currencySymbol,
currency.blockchain.fullName,
)
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.wallet.ui
import android.content.Context
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
@ -9,6 +10,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionInflater
@ -16,6 +18,7 @@ import by.kirich1409.viewbindingdelegate.viewBinding
import coil.load
import coil.size.Scale
import com.tangem.core.ui.fragments.setStatusBarColor
import com.tangem.core.ui.utils.OneTouchClickListener
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.tap.MainActivity
import com.tangem.tap.common.analytics.Analytics
@ -59,6 +62,13 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
private val viewModel by viewModels<WalletViewModel>()
override fun onAttach(context: Context) {
super.onAttach(context)
activity?.lifecycleScope?.launchWhenCreated {
viewModel.launch()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@ -81,7 +91,6 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(R.transition.slide_right)
exitTransition = inflater.inflateTransition(R.transition.fade)
viewModel.launch()
}
override fun onStart() {
@ -110,9 +119,9 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
super.onViewCreated(view, savedInstanceState)
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener {
store.dispatch(WalletAction.ChangeWallet)
}
binding.toolbar.setNavigationOnClickListener(
OneTouchClickListener { store.dispatch(WalletAction.ChangeWallet) },
)
setupWarningsRecyclerView()
walletView.changeWalletView(this, binding)
addCustomActionOnCard()

View file

@ -8,8 +8,10 @@ import com.tangem.tap.store
import com.tangem.tap.userWalletsListManager
import com.tangem.tap.walletStoresManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -21,8 +23,10 @@ internal class WalletViewModel : ViewModel() {
private fun bootstrapSelectedWalletStoresChanges() {
userWalletsListManager.selectedUserWallet
.flatMapLatest { selectedWallet ->
walletStoresManager.get(selectedWallet.walletId)
.map { it.walletId }
.distinctUntilChanged()
.flatMapLatest { selectedUserWalletId ->
walletStoresManager.get(selectedUserWalletId)
}
.onEach { walletStores ->
store.dispatch(WalletAction.WalletStoresChanged(walletStores))

View file

@ -6,6 +6,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.tangem.tap.common.extensions.beginDelayedTransition
import com.tangem.tap.common.extensions.fitChipsByGroupWidth
import com.tangem.tap.common.extensions.getQuantityString
import com.tangem.tap.common.extensions.getString
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsState
@ -65,7 +66,7 @@ class SingleWalletView : WalletView() {
setupTwinCards(state.twinCardsState, binding)
setupButtons(state.primaryWallet, binding, state.isExchangeServiceFeatureOn)
setupAddressCard(state.primaryWallet, binding)
setupAddressCard(state, binding)
showPendingTransactionsIfPresent(state.primaryWallet.pendingTransactions)
setupBalance(state, state.primaryWallet)
}
@ -152,35 +153,50 @@ class SingleWalletView : WalletView() {
}
}
private fun setupAddressCard(state: WalletData, binding: FragmentWalletBinding) = with(binding.lAddress) {
if (state.walletAddresses != null && state.currency is Currency.Blockchain) {
private fun setupAddressCard(state: WalletState, binding: FragmentWalletBinding) = with(binding.lAddress) {
val primaryWallet = state.primaryWallet
if (primaryWallet?.walletAddresses != null && primaryWallet.currency is Currency.Blockchain) {
binding.lAddress.root.show()
if (state.shouldShowMultipleAddress()) {
if (primaryWallet.shouldShowMultipleAddress()) {
(binding.lAddress.root as? ViewGroup)?.beginDelayedTransition()
chipGroupAddressType.show()
chipGroupAddressType.fitChipsByGroupWidth()
val checkedId = MultipleAddressUiHelper.typeToId(state.walletAddresses.selectedAddress.type)
val checkedId = MultipleAddressUiHelper.typeToId(primaryWallet.walletAddresses.selectedAddress.type)
if (checkedId != View.NO_ID) chipGroupAddressType.check(checkedId)
chipGroupAddressType.setOnCheckedChangeListener { group, checkedId ->
if (checkedId == -1) return@setOnCheckedChangeListener
val type = MultipleAddressUiHelper.idToType(checkedId, state.currency.blockchain)
val type = MultipleAddressUiHelper.idToType(checkedId, primaryWallet.currency.blockchain)
type?.let { store.dispatch(WalletAction.ChangeSelectedAddress(type)) }
}
} else {
chipGroupAddressType.hide()
}
tvAddress.text = state.walletAddresses.selectedAddress.address
tvAddress.text = primaryWallet.walletAddresses.selectedAddress.address
tvExplore.setOnClickListener {
store.dispatch(
WalletAction.ExploreAddress(
state.walletAddresses.selectedAddress.exploreUrl,
primaryWallet.walletAddresses.selectedAddress.exploreUrl,
fragment!!.requireContext(),
),
)
}
setupCardInfo(state)
} else {
binding.lAddress.root.hide()
}
}
private fun setupCardInfo(state: WalletState) {
val textView = binding?.lAddress?.tvInfo
val blockchain = state.primaryWallet?.currency?.blockchain
if (textView != null && blockchain != null) {
textView.text = textView.getString(
id = R.string.address_qr_code_message_format,
blockchain.fullName,
blockchain.currency,
blockchain.fullName,
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.tap.features.walletSelector.redux
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.flatMap
@ -19,6 +20,7 @@ import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.model.WalletStoreModel
import com.tangem.tap.domain.model.builders.UserWalletBuilder
import com.tangem.tap.domain.scanCard.ScanCardProcessor
import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
@ -131,21 +133,46 @@ internal class WalletSelectorMiddleware {
private fun addWallet() = scope.launch {
Analytics.send(MyWallets.Button.ScanNewCard)
scanCardInternal { scanResponse ->
val userWallet = UserWalletBuilder(scanResponse).build()
val prevUseBiometricsForAccessCode = tangemSdkManager.useBiometricsForAccessCode()
userWalletsListManager.save(userWallet)
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.AddWallet.Error(error))
}
.doOnSuccess {
Analytics.send(MyWallets.CardWasScanned)
// Update access code policy for access code saving when a card was scanned
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = preferencesStorage.shouldSaveAccessCodes,
)
ScanCardProcessor.scan(
onSuccess = { scanResponse ->
saveUserWalletAndPopBackToWalletScreen(scanResponse)
.doOnFailure { error ->
// Rollback policy if card saving was failed
tangemSdkManager.setAccessCodeRequestPolicy(prevUseBiometricsForAccessCode)
store.dispatchOnMain(WalletSelectorAction.AddWallet.Error(error))
}
},
onFailure = { error ->
// Rollback policy if card scanning was failed
tangemSdkManager.setAccessCodeRequestPolicy(prevUseBiometricsForAccessCode)
store.dispatchOnMain(WalletSelectorAction.AddWallet.Error(error))
},
onWalletNotCreated = {
// No need to rollback policy, continue with the policy set before the card scan
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
},
)
}
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(userWallet)
}
}
private suspend fun saveUserWalletAndPopBackToWalletScreen(scanResponse: ScanResponse): CompletionResult<Unit> {
val userWallet = UserWalletBuilder(scanResponse).build()
?: return CompletionResult.Failure(TangemSdkError.WalletIsNotCreated())
return userWalletsListManager.save(userWallet)
.doOnSuccess {
Analytics.send(MyWallets.CardWasScanned)
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(userWallet)
}
}
private fun selectWallet(userWalletId: UserWalletId) {
@ -212,9 +239,7 @@ internal class WalletSelectorMiddleware {
Analytics.send(MyWallets.Button.EditWalletTapped)
scope.launch {
userWalletsListManager.get(userWalletId)
.map { it.copy(name = newName) }
.flatMap { userWalletsListManager.save(it, canOverride = true) }
userWalletsListManager.update(userWalletId) { it.copy(name = newName) }
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.HandleError(error))
}
@ -252,43 +277,32 @@ internal class WalletSelectorMiddleware {
.flatMap { walletStoresManager.delete(userWalletsIds) }
.flatMap { deleteAccessCodes(userWalletsIds) }
.doOnSuccess {
val selectedWallet = userWalletsListManager.selectedUserWalletSync
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync
when {
selectedWallet == null -> {
selectedUserWallet == null -> {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Welcome))
}
currentSelectedWalletId != selectedWallet.walletId -> {
store.onUserWalletSelected(selectedWallet)
currentSelectedWalletId != selectedUserWallet.walletId -> {
store.onUserWalletSelected(selectedUserWallet)
}
}
}
}
private suspend inline fun scanCardInternal(
crossinline onCardScanned: suspend (ScanResponse) -> Unit,
) {
ScanCardProcessor.scan(
onSuccess = {
onCardScanned(it)
},
onFailure = { error ->
store.dispatchOnMain(WalletSelectorAction.AddWallet.Error(error))
},
onWalletNotCreated = {
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
store.dispatchOnMain(NavigationAction.PopBackTo())
},
)
}
private suspend fun deleteAccessCodes(userWalletsIds: List<UserWalletId>): CompletionResult<Unit> {
val cardsIds = userWalletsListManager.userWallets.firstOrNull().orEmpty()
.asSequence()
.filter { it.walletId in userWalletsIds }
.flatMap { it.cardsInWallet }
val cardsIds = userWalletsListManager.userWallets.firstOrNull()
?.asSequence()
?.filter { it.walletId in userWalletsIds }
?.flatMap { it.cardsInWallet }
?.toSet()
return tangemSdkManager.deleteSavedUserCodes(cardsIds.toSet())
return if (cardsIds.isNullOrEmpty()) {
CompletionResult.Success(Unit)
} else {
tangemSdkManager.deleteSavedUserCodes(cardsIds.toSet())
}
}
private suspend fun UserWalletModel.updateWalletStoresAndCalculateFiatBalance(

View file

@ -38,7 +38,6 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
fun walletClicked(userWalletId: UserWalletId) = with(state.value) {
when {
editingUserWalletsIds.isNotEmpty() && isWalletLocked(userWalletId, this) -> Unit
editingUserWalletsIds.isNotEmpty() && !editingUserWalletsIds.contains(userWalletId) -> {
editWallet(userWalletId)
}
@ -52,7 +51,7 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
}
fun walletLongClicked(userWalletId: UserWalletId) = with(state.value) {
if (!isWalletLocked(userWalletId, this) && editingUserWalletsIds.isEmpty()) {
if (editingUserWalletsIds.isEmpty()) {
editWallet(userWalletId)
}
}
@ -67,15 +66,15 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
fun renameWallet() = with(state.value) {
if (editingUserWalletsIds.isNotEmpty() && renameWalletDialog == null) {
val editedWalletId = editingUserWalletsIds.first()
val editedWallet = (multiCurrencyWallets + singleCurrencyWallets)
.find { it.id == editedWalletId }
val editedUserWalletId = editingUserWalletsIds.first()
val editedUserWallet = (multiCurrencyWallets + singleCurrencyWallets)
.find { it.id == editedUserWalletId }
if (editedWallet != null) {
if (editedUserWallet != null) {
val dialog = RenameWalletDialog(
currentName = editedWallet.name,
currentName = editedUserWallet.name,
onApply = { newName ->
store.dispatch(WalletSelectorAction.RenameWallet(editedWalletId, newName))
store.dispatch(WalletSelectorAction.RenameWallet(editedUserWalletId, newName))
stateInternal.update { prevState ->
prevState.copy(
renameWalletDialog = null,
@ -137,11 +136,6 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
}
}
private fun isWalletLocked(userWalletId: UserWalletId, state: WalletSelectorScreenState): Boolean = with(state) {
multiCurrencyWallets.find { it.id == userWalletId }?.isLocked
?: singleCurrencyWallets.find { it.id == userWalletId }?.isLocked ?: isLocked
}
private fun subscribeToStoreChanges() {
store.subscribe(this) { appState ->
appState.skip { old, new -> old.walletSelectorState == new.walletSelectorState }

View file

@ -71,7 +71,7 @@ internal class WelcomeMiddleware {
private fun proceedWithCard(state: WelcomeState) = scope.launch {
scanCardInternal { scanResponse ->
val userWallet = UserWalletBuilder(scanResponse).build()
val userWallet = UserWalletBuilder(scanResponse).build() ?: return@scanCardInternal
userWalletsListManager.save(userWallet, canOverride = true)
.doOnFailure { error ->

View file

@ -8,12 +8,12 @@ import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.common.util.userWalletId
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.lib.crypto.models.Currency
import com.tangem.lib.crypto.models.Currency.NativeToken
import com.tangem.lib.crypto.models.Currency.NonNativeToken
import com.tangem.tap.domain.extensions.makeWalletManagerForApp
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
import com.tangem.tap.features.wallet.redux.WalletAction
import org.rekotlin.Action
@ -54,7 +54,12 @@ class UserWalletManagerImpl(
}
override fun getWalletId(): String {
return appStateHolder.getActualCard()?.userWalletId?.stringValue ?: ""
return appStateHolder.getActualCard()?.let {
UserWalletIdBuilder.card(it)
.build()
?.stringValue
}
?: ""
}
override suspend fun isTokenAdded(currency: Currency): Boolean {