Updated on 2026-08-14

This commit is contained in:
Tangem 2022-12-14 19:16:49 +03:00
commit c6c1fd3044
42 changed files with 430 additions and 297 deletions

View file

@ -14,6 +14,11 @@ sealed class MainScreen(
class CardWasScanned : MainScreen("Card Was Scanned")
class ButtonMyWallets : MainScreen("Button - My Wallets")
class EnableBiometrics(state: AnalyticsParam.OnOffState) : MainScreen(
event = "Enable Biometric",
params = mapOf("State" to state.value),
)
class MainCurrencyChanged(currencyType: AnalyticsParam.CurrencyType) : MainScreen(
event = "Main Currency Changed",
params = mapOf("Currency Type" to currencyType.value),

View file

@ -0,0 +1,18 @@
package com.tangem.tap.common.analytics.events
sealed class MyWallets(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("My Wallets", event, params) {
object MyWalletsScreenOpened : MyWallets(event = "My Wallets Screen Opened")
object CardWasScanned : MyWallets(event = "Card Was Scanned")
object WalletUnlockTapped : MyWallets(event = "Wallet Unlock Tapped")
object Button {
object ScanNewCard : MyWallets(event = "Button - Scan New Card")
object UnlockWithBiometrics : MyWallets(event = "Button - Unlock all with Face ID")
object EditWalletTapped : MyWallets(event = "Button - Edit Wallet Tapped")
object DeleteWalletTapped : MyWallets(event = "Button - Delete Wallet Tapped")
}
}

View file

@ -73,4 +73,9 @@ sealed class Onboarding(
class ClaimScreenOpened : Onboarding("Onboarding", "Claim screen opened")
class ButtonClaim : Onboarding("Onboarding", "Button - Claim")
class ClaimWasSuccessfully : Onboarding("Onboarding", "Claim was successfully")
class EnableBiometrics(state: AnalyticsParam.OnOffState) : Onboarding(
category = "Onboarding / Biometric",
event = "Enable Biometric",
params = mapOf("State" to state.value),
)
}

View file

@ -58,16 +58,16 @@ sealed class Settings(
params: Map<String, String> = mapOf(),
) : Settings("Settings / App Settings", event, params) {
sealed class FaceIDSwitcherChanged(state: AnalyticsParam.OnOffState) : CardSettings(
event = "Face ID Switcher Changed",
class SaveWalletSwitcherChanged(state: AnalyticsParam.OnOffState) : CardSettings(
event = "Save Wallet Switcher Changed",
params = mapOf("State" to state.value),
)
sealed class SaveAccessCodeSwitcherChanged(state: AnalyticsParam.OnOffState) : CardSettings(
class SaveAccessCodeSwitcherChanged(state: AnalyticsParam.OnOffState) : CardSettings(
event = "Save Access Code Switcher Changed",
params = mapOf("State" to state.value),
)
class ButtonEnableBiometricAuthentication : AppSettings("Button - Enable Biometric Authentication")
object ButtonEnableBiometricAuthentication : AppSettings("Button - Enable Biometric Authentication")
}
}

View file

@ -32,10 +32,8 @@ val navigationMiddleware: Middleware<AppState> = { _, state ->
AppScreen.Home,
AppScreen.Welcome,
-> {
navState?.activity?.get()?.popBackTo(screen, action.inclusive)
if (navState?.backStack?.contains(screen) != true) {
store.dispatchOnMain(NavigationAction.NavigateTo(screen))
}
navState?.activity?.get()?.popBackTo(screen, inclusive = true)
store.dispatchOnMain(NavigationAction.NavigateTo(screen))
}
else -> {
navState?.activity?.get()?.popBackTo(screen, action.inclusive)

View file

@ -38,7 +38,6 @@ import com.tangem.tap.domain.tasks.product.CreateProductWalletTaskResponse
import com.tangem.tap.domain.tasks.product.ResetToFactorySettingsTask
import com.tangem.tap.domain.tasks.product.ScanProductTask
import com.tangem.tap.domain.tokens.UserTokensRepository
import com.tangem.tap.domain.userWalletList.di.USER_WALLETS_BIOMETRIC_KEY_NAME
import com.tangem.wallet.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
@ -119,16 +118,6 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
.map { CardDTO(it) }
}
suspend fun unlockBiometricKeys(): CompletionResult<Unit> {
return biometricManager.authenticate(
mode = BiometricManager.AuthenticationMode.Keys(
USER_WALLETS_BIOMETRIC_KEY_NAME,
tangemSdk.config.userCodesBiometricKeyName,
),
)
.map { /* no-op */ }
}
suspend fun saveAccessCode(accessCode: String, cardsIds: Set<String>): CompletionResult<Unit> {
return createUserCodeRepository().save(
cardIds = cardsIds,
@ -137,20 +126,10 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
stringValue = accessCode,
),
)
.map {
biometricManager.unauthenticate(
keyName = tangemSdk.config.userCodesBiometricKeyName,
)
}
}
suspend fun clearSavedUserCodes(): CompletionResult<Unit> {
return createUserCodeRepository().clear()
.map {
biometricManager.unauthenticate(
keyName = tangemSdk.config.userCodesBiometricKeyName,
)
}
}
suspend fun setPasscode(cardId: String?): CompletionResult<SuccessResponse> {
@ -229,7 +208,7 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
}
fun getString(@StringRes stringResId: Int, vararg formatArgs: Any?): String {
return context.getString(stringResId, formatArgs)
return context.getString(stringResId, *formatArgs)
}
fun setAccessCodeRequestPolicy(

View file

@ -1,5 +1,6 @@
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
@ -12,6 +13,9 @@ import com.tangem.domain.common.util.UserWalletId
* @param scanResponse [ScanResponse] of primary user's wallet card.
* TODO: Replace with [com.tangem.domain.common.CardDTO]
* @property cardId ID of user's wallet primary card
* @property hasAccessCode Indicates if the user's wallet primary card has access code
* @property isLocked Indicates if this primary card has no currency wallets
* @property isSaved Indicates if this user wallet is saved
* */
data class UserWallet(
val name: String,
@ -23,5 +27,27 @@ data class UserWallet(
val cardId: String
get() = scanResponse.card.cardId
val hasAccessCode: Boolean
get() = scanResponse.card.isAccessCodeSet
val isLocked: Boolean
get() = scanResponse.card.wallets.isEmpty()
internal var isSaved: Boolean = true
}
/**
* !!! Workaround !!!
*
* Calculate same [UserWalletId] for twins instead
* */
fun UserWallet.isTwinnedWith(other: UserWallet): Boolean {
if (!scanResponse.isTangemTwins()) 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

@ -59,7 +59,7 @@ class CreateProductWalletTask(
private val type: ProductType,
) : CardSessionRunnable<CreateProductWalletTaskResponse> {
override val allowsAccessCodeFromRepository: Boolean = false
override val allowsRequestAccessCodeFromRepository: Boolean = false
override fun run(
session: CardSession,

View file

@ -45,7 +45,7 @@ class ScanProductTask(
private val additionalBlockchainsToDerive: Collection<Blockchain>? = null,
) : CardSessionRunnable<ScanResponse> {
override val allowsAccessCodeFromRepository: Boolean
override val allowsRequestAccessCodeFromRepository: Boolean
get() = !additionalBlockchainsToDerive.isNullOrEmpty()
override fun run(

View file

@ -4,13 +4,12 @@ import com.tangem.common.CompletionResult
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.TwinsHelper
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.operations.wallet.CreateWalletTask
import com.tangem.operations.wallet.PurgeWalletCommand
class CreateFirstTwinWalletTask : CardSessionRunnable<CreateWalletResponse> {
class CreateFirstTwinWalletTask(private val firstCardId: String) : CardSessionRunnable<CreateWalletResponse> {
override fun run(
session: CardSession,
callback: (result: CompletionResult<CreateWalletResponse>) -> Unit,
@ -18,8 +17,11 @@ class CreateFirstTwinWalletTask : CardSessionRunnable<CreateWalletResponse> {
val card = session.environment.card
val publicKey = card?.wallets?.firstOrNull()?.publicKey
if (publicKey != null) {
if (TwinsHelper.getTwinCardNumber(card.cardId) == TwinCardNumber.Second) {
callback(CompletionResult.Failure(WrongTwinCard(TwinCardNumber.First)))
val requiredTwinCardNumber = TwinsHelper.getTwinCardNumber(firstCardId)
if (requiredTwinCardNumber != TwinsHelper.getTwinCardNumber(card.cardId)) {
requiredTwinCardNumber?.let {
callback(CompletionResult.Failure(WrongTwinCard(it)))
}
return
}

View file

@ -7,7 +7,6 @@ import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.extensions.hexToBytes
import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.TwinsHelper
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.operations.wallet.CreateWalletTask
@ -15,6 +14,7 @@ import com.tangem.operations.wallet.PurgeWalletCommand
class CreateSecondTwinWalletTask(
private val firstPublicKey: String,
private val firstCardId: String,
private val issuerKeys: KeyPair,
private val preparingMessage: Message,
private val creatingWalletMessage: Message,
@ -24,8 +24,11 @@ class CreateSecondTwinWalletTask(
val card = session.environment.card
val publicKey = card?.wallets?.firstOrNull()?.publicKey
if (publicKey != null) {
if (TwinsHelper.getTwinCardNumber(card.cardId) == TwinCardNumber.First) {
callback(CompletionResult.Failure(WrongTwinCard(TwinCardNumber.Second)))
val currentTwinCardNumber = TwinsHelper.getTwinCardNumber(card.cardId)
if (TwinsHelper.getTwinCardNumber(firstCardId) == currentTwinCardNumber) {
currentTwinCardNumber?.pairNumber()?.let {
callback(CompletionResult.Failure(WrongTwinCard(it)))
}
return
}

View file

@ -8,9 +8,9 @@ import com.tangem.common.CompletionResult
import com.tangem.common.KeyPair
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.tap.common.AssetReader
import com.tangem.tap.tangemSdkManager
@ -30,7 +30,7 @@ class TwinCardsManager(
suspend fun createFirstWallet(message: Message): CompletionResult<CreateWalletResponse> {
val response = tangemSdkManager.runTaskAsync(
runnable = CreateFirstTwinWalletTask(),
runnable = CreateFirstTwinWalletTask(firstCardId),
cardId = firstCardId,
initialMessage = message,
)
@ -48,6 +48,7 @@ class TwinCardsManager(
): CompletionResult<CreateWalletResponse> {
val task = CreateSecondTwinWalletTask(
firstPublicKey = currentCardPublicKey!!,
firstCardId = firstCardId,
issuerKeys = issuerKeyPair,
preparingMessage = preparingMessage,
creatingWalletMessage = creatingWalletMessage,

View file

@ -14,10 +14,13 @@ import com.tangem.tap.domain.userWalletList.repository.implementation.BiometricU
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultSelectedUserWalletRepository
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsPublicInformationRepository
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsSensitiveInformationRepository
import com.tangem.tap.domain.userWalletList.utils.json.*
import com.tangem.tap.domain.userWalletList.utils.json.ByteArrayKeyAdapter
import com.tangem.tap.domain.userWalletList.utils.json.CardBackupStatusAdapter
import com.tangem.tap.domain.userWalletList.utils.json.ExtendedPublicKeysMapAdapter
import com.tangem.tap.domain.userWalletList.utils.json.ScanResponseDerivedKeysMapAdapter
import com.tangem.tap.domain.userWalletList.utils.json.WalletDerivedKeysMapAdapter
const val USER_WALLETS_STORAGE_NAME = "user_wallets_storage"
const val USER_WALLETS_BIOMETRIC_KEY_NAME = "user_wallets"
private const val USER_WALLETS_STORAGE_NAME = "user_wallets_storage"
fun UserWalletsListManager.Companion.provideBiometricImplementation(
context: Context,
@ -43,7 +46,6 @@ fun UserWalletsListManager.Companion.provideBiometricImplementation(
)
val keysRepository = BiometricUserWalletsKeysRepository(
biometricKeyName = USER_WALLETS_BIOMETRIC_KEY_NAME,
moshi = moshi,
secureStorage = secureStorage,
biometricManager = tangemSdkManager.biometricManager,
@ -61,7 +63,6 @@ fun UserWalletsListManager.Companion.provideBiometricImplementation(
)
return BiometricUserWalletsListManager(
tangemSdkManager = tangemSdkManager,
keysRepository = keysRepository,
publicInformationRepository = publicInformationRepository,
sensitiveInformationRepository = sensitiveInformationRepository,

View file

@ -2,9 +2,8 @@ package com.tangem.tap.domain.userWalletList.implementation
import com.tangem.common.*
import com.tangem.domain.common.util.UserWalletId
import com.tangem.domain.common.util.encryptionKey
import com.tangem.tap.domain.TangemSdkManager
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
@ -20,7 +19,6 @@ import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class)
internal class BiometricUserWalletsListManager(
private val tangemSdkManager: TangemSdkManager,
private val keysRepository: UserWalletsKeysRepository,
private val publicInformationRepository: UserWalletsPublicInformationRepository,
private val sensitiveInformationRepository: UserWalletsSensitiveInformationRepository,
@ -64,10 +62,22 @@ internal class BiometricUserWalletsListManager(
override suspend fun unlockWithCard(userWallet: UserWallet): CompletionResult<Unit> {
state.update { prevState ->
userWallet.isSaved = false
// If the previous state contains a saved user wallet with the same ID, it is also saved
userWallet.isSaved = prevState.wallets.any {
it.walletId == userWallet.walletId && it.isSaved
}
val newEncryptionKeys = prevState.encryptionKeys
.plus(UserWalletEncryptionKey(userWallet))
.distinctBy { it.walletId }
val newUserWallets = prevState.wallets
.plus(userWallet)
.distinctBy { it.walletId }
prevState.copy(
encryptionKeys = listOf(UserWalletEncryptionKey(userWallet)),
wallets = listOf(userWallet),
encryptionKeys = newEncryptionKeys,
wallets = newUserWallets,
selectedWalletId = userWallet.walletId,
)
}
@ -75,15 +85,13 @@ internal class BiometricUserWalletsListManager(
.map {
state.update { prevState ->
prevState.copy(
selectedWalletId = userWallet.walletId,
isLocked = prevState.wallets.size != 1,
isLocked = prevState.wallets.any { it.isLocked },
)
}
}
}
override fun lock() {
tangemSdkManager.biometricManager.unauthenticate()
state.update { prevState ->
prevState.copy(
encryptionKeys = emptyList(),
@ -116,20 +124,23 @@ internal class BiometricUserWalletsListManager(
): CompletionResult<Unit> = withUnlock {
val isWalletSaved = state.value.wallets
.filter { it.isSaved }
.flatMap(UserWallet::cardsInWallet)
.contains(userWallet.cardId)
.any {
// Workaround, check [UserWallet.isTwinnedWith]
it.cardsInWallet.contains(userWallet.cardId) || it.isTwinnedWith(userWallet)
}
if (isWalletSaved && !canOverride) {
CompletionResult.Failure(UserWalletListError.WalletAlreadySaved)
} else {
keysRepository.save(
walletId = userWallet.walletId,
encryptionKey = userWallet.scanResponse.card.encryptionKey,
)
.doOnSuccess { keys ->
val newEncryptionKeys = state.value.encryptionKeys
.plus(UserWalletEncryptionKey(userWallet))
.distinctBy { it.walletId }
keysRepository.store(newEncryptionKeys)
.doOnSuccess {
state.update { prevState ->
prevState.copy(
encryptionKeys = (keys + prevState.encryptionKeys).distinctBy { it.walletId },
encryptionKeys = newEncryptionKeys,
)
}
}
@ -143,28 +154,25 @@ internal class BiometricUserWalletsListManager(
}
override suspend fun delete(walletIds: List<UserWalletId>): CompletionResult<Unit> {
if (state.value.isLocked) {
return CompletionResult.Success(Unit)
}
val walletIdsToRemove = state.value.wallets
.map { it.walletId }
.filter { it in walletIds }
val remainingEncryptionKeys = state.value.encryptionKeys
.filter { it.walletId !in walletIdsToRemove }
changeSelectedWalletIfNeeded(walletIdsToRemove)
return sensitiveInformationRepository.delete(walletIdsToRemove)
.flatMap { publicInformationRepository.delete(walletIdsToRemove) }
.flatMap { keysRepository.delete(walletIdsToRemove) }
.map { keys ->
.flatMap { keysRepository.store(remainingEncryptionKeys) }
.map {
state.update { prevState ->
prevState.copy(
encryptionKeys = keys,
encryptionKeys = remainingEncryptionKeys,
wallets = prevState.wallets.filter { it.walletId !in walletIdsToRemove },
)
}
}
.flatMap { loadModels() }
}
override suspend fun clear(): CompletionResult<Unit> {
@ -175,12 +183,11 @@ internal class BiometricUserWalletsListManager(
.flatMap { keysRepository.clear() }
.map {
selectedUserWalletRepository.set(null)
tangemSdkManager.biometricManager.unauthenticate()
state.update { State() }
}
}
override suspend fun get(walletId: UserWalletId): CompletionResult<UserWallet> = withUnlock {
override suspend fun get(walletId: UserWalletId): CompletionResult<UserWallet> {
return catching {
state.value.wallets.first { it.walletId == walletId }
}

View file

@ -1,12 +1,10 @@
package com.tangem.tap.domain.userWalletList.repository
import com.tangem.common.CompletionResult
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
internal interface UserWalletsKeysRepository {
suspend fun getAll(): CompletionResult<List<UserWalletEncryptionKey>>
suspend fun save(walletId: UserWalletId, encryptionKey: ByteArray): CompletionResult<List<UserWalletEncryptionKey>>
suspend fun delete(walletIds: List<UserWalletId>): CompletionResult<List<UserWalletEncryptionKey>>
suspend fun store(encryptionKeys: List<UserWalletEncryptionKey>): CompletionResult<Unit>
suspend fun clear(): CompletionResult<Unit>
}

View file

@ -6,24 +6,19 @@ import com.squareup.moshi.Types
import com.tangem.common.CompletionResult
import com.tangem.common.biometric.BiometricManager
import com.tangem.common.biometric.BiometricStorage
import com.tangem.common.flatMap
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.common.extensions.replaceByOrAdd
import com.tangem.tap.domain.userWalletList.UserWalletListError
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysRepository
internal class BiometricUserWalletsKeysRepository(
biometricKeyName: String,
moshi: Moshi,
secureStorage: SecureStorage,
biometricManager: BiometricManager,
) : UserWalletsKeysRepository {
private val biometricStorage = BiometricStorage(
biometricKeyName = biometricKeyName,
biometricManager = biometricManager,
secureStorage = secureStorage,
)
@ -41,50 +36,16 @@ internal class BiometricUserWalletsKeysRepository(
}
}
override suspend fun save(
walletId: UserWalletId,
encryptionKey: ByteArray,
): CompletionResult<List<UserWalletEncryptionKey>> {
return getAll()
.flatMap { keys ->
if (keys.any { it.walletId == walletId }) {
return@flatMap CompletionResult.Success(Unit)
}
val encodedKeys = keys.toMutableList()
.apply {
replaceByOrAdd(UserWalletEncryptionKey(walletId, encryptionKey)) {
it.walletId == walletId
}
}
.encode()
biometricStorage.store(
key = StorageKey.WalletEncryptionKeys.name,
data = encodedKeys,
)
}
.flatMap { getAll() }
override suspend fun store(encryptionKeys: List<UserWalletEncryptionKey>): CompletionResult<Unit> {
return biometricStorage.store(
key = StorageKey.WalletEncryptionKeys.name,
data = encryptionKeys.encode(),
)
.mapFailure { error ->
UserWalletListError.SaveEncryptionKeysError(error.cause ?: error)
}
}
override suspend fun delete(walletIds: List<UserWalletId>): CompletionResult<List<UserWalletEncryptionKey>> {
return getAll()
.map { keys ->
val keysToRemove = keys.filter { it.walletId in walletIds }.toSet()
(keys - keysToRemove).encode()
}
.flatMap { encodedKeys ->
biometricStorage.store(
key = StorageKey.WalletEncryptionKeys.name,
data = encodedKeys,
)
}
.flatMap { getAll() }
}
override suspend fun clear(): CompletionResult<Unit> {
return biometricStorage.delete(key = StorageKey.WalletEncryptionKeys.name)
}

View file

@ -165,6 +165,14 @@ internal class DefaultWalletStoresManager(
.build(),
)
}
.flatMapOnFailure { error ->
when (error) {
is WalletStoresError.WalletManagerNotCreated,
is WalletStoresError.UpdateWalletManagerError,
-> CompletionResult.Success(Unit)
else -> CompletionResult.Failure(error)
}
}
}
internal data class State(

View file

@ -121,8 +121,12 @@ class DetailsMiddleware {
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync
if (selectedUserWallet != null) {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(selectedUserWallet)
if (userWalletsListManager.isLockedSync) {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Welcome))
} else {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(selectedUserWallet)
}
} else {
userWalletsListManager.lock()
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Home))
@ -232,6 +236,7 @@ class DetailsMiddleware {
}
private fun enrollBiometrics() {
Analytics.send(Settings.AppSettings.ButtonEnableBiometricAuthentication)
store.dispatchOnMain(NavigationAction.OpenBiometricsSettings)
}
@ -268,14 +273,18 @@ class DetailsMiddleware {
Timber.e(error, "Wallet saving failed")
}
.doOnSuccess {
Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.On))
preferencesStorage.shouldShowSaveUserWalletScreen = false
preferencesStorage.shouldSaveUserWallets = true
store.dispatchOnMain(
DetailsAction.AppSettings.SwitchPrivacySetting.Success(
setting = PrivacySetting.SaveWallets,
enable = true,
),
)
store.onUserWalletSelected(userWallet)
}
}
@ -284,22 +293,29 @@ class DetailsMiddleware {
userWalletsListManager.clear()
.flatMap { walletStoresManager.clear() }
.doOnSuccess {
Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.Off))
preferencesStorage.shouldSaveUserWallets = false
store.dispatchOnMain(
DetailsAction.AppSettings.SwitchPrivacySetting.Success(
setting = PrivacySetting.SaveWallets,
enable = false,
),
)
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Home))
}
}
private fun saveAccessCodes(state: DetailsState) {
Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.On))
preferencesStorage.shouldSaveAccessCodes = true
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = state.scanResponse?.card?.isAccessCodeSet == true,
)
store.dispatchOnMain(
DetailsAction.AppSettings.SwitchPrivacySetting.Success(
setting = PrivacySetting.SaveAccessCode,
@ -311,10 +327,13 @@ class DetailsMiddleware {
private suspend fun deleteSavedAccessCodes() {
tangemSdkManager.clearSavedUserCodes()
.doOnSuccess {
Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off))
preferencesStorage.shouldSaveAccessCodes = false
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = false,
)
store.dispatchOnMain(
DetailsAction.AppSettings.SwitchPrivacySetting.Success(
setting = PrivacySetting.SaveAccessCode,

View file

@ -93,9 +93,14 @@ private fun readCard() = scope.launch {
userWalletsListManager.save(userWallet)
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")
tangemSdkManager.setAccessCodeRequestPolicy(useBiometricsForAccessCode = false)
store.onCardScanned(scanResponse)
}
.doOnSuccess {
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = preferencesStorage.shouldSaveAccessCodes &&
userWallet.hasAccessCode,
)
store.onUserWalletSelected(userWallet)
}
.doOnResult {

View file

@ -1,16 +1,20 @@
package com.tangem.tap.features.onboarding
import com.tangem.common.doOnSuccess
import com.tangem.domain.common.ProductType
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onCardScanned
import com.tangem.tap.common.extensions.onUserWalletSelected
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.features.saveWallet.redux.SaveWalletAction
import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.tap.userWalletsListManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -55,6 +59,18 @@ class OnboardingHelper {
backupCardsIds: List<String>? = null,
) {
when {
// When should save user wallets but manager is locked, then unlock manager with card
preferencesStorage.shouldSaveUserWallets &&
userWalletsListManager.isLockedSync -> scope.launch {
val userWallet = UserWalletBuilder(scanResponse).build()
tangemSdkManager.setAccessCodeRequestPolicy(useBiometricsForAccessCode = false)
userWalletsListManager.unlockWithCard(userWallet)
.doOnSuccess {
store.onUserWalletSelected(userWallet)
}
}
// When should save user wallets, then save card without navigate to save wallet screen
preferencesStorage.shouldSaveUserWallets -> scope.launch {
store.dispatchOnMain(
SaveWalletAction.ProvideBackupInfo(
@ -65,6 +81,8 @@ class OnboardingHelper {
)
store.dispatchOnMain(SaveWalletAction.Save)
}
// When should not save user wallets but device has biometry and save wallet screen has not been shown,
// then open save wallet screen
tangemSdkManager.canUseBiometry &&
preferencesStorage.shouldShowSaveUserWalletScreen -> scope.launch {
delay(timeMillis = 1_200)
@ -77,6 +95,7 @@ class OnboardingHelper {
)
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.SaveWallet))
}
// If device has no biometry and save wallet screen has been shown, then go through old scenario
else -> scope.launch {
store.onCardScanned(scanResponse)
}

View file

@ -234,7 +234,6 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
when (action) {
is BackupAction.StartBackup -> {
tangemSdkManager.setAccessCodeRequestPolicy(useBiometricsForAccessCode = false)
Analytics.send(Onboarding.Backup.Started())
backupService.discardSavedBackup()
val primaryCard = scanResponse?.primaryCard

View file

@ -4,6 +4,10 @@ import com.tangem.common.CompletionResult
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.flatMap
import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.MainScreen
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
@ -36,9 +40,9 @@ internal class SaveWalletMiddleware {
is SaveWalletAction.Save -> saveWalletIfBiometricsEnrolled(state)
is SaveWalletAction.EnrollBiometrics.Enroll -> enrollBiometrics()
is SaveWalletAction.SaveWalletWasShown -> saveWalletWasShown()
is SaveWalletAction.Dismiss -> dismiss(state)
is SaveWalletAction.Save.Success,
is SaveWalletAction.ProvideBackupInfo,
is SaveWalletAction.Dismiss,
is SaveWalletAction.CloseError,
is SaveWalletAction.Save.Error,
is SaveWalletAction.EnrollBiometrics,
@ -74,6 +78,13 @@ internal class SaveWalletMiddleware {
?: store.state.globalState.scanResponse
?: return
if (state.backupInfo != null) {
// TODO: Remove after onboarding refactoring
Analytics.send(Onboarding.EnableBiometrics(AnalyticsParam.OnOffState.On))
} else {
Analytics.send(MainScreen.EnableBiometrics(AnalyticsParam.OnOffState.On))
}
scope.launch {
val userWallet = UserWalletBuilder(scanResponse)
.backupCardsIds(state.backupInfo?.backupCardsIds)
@ -83,6 +94,7 @@ internal class SaveWalletMiddleware {
saveAccessCodeIfNeeded(state.backupInfo?.accessCode, userWallet.cardsInWallet)
.flatMap { userWalletsListManager.save(userWallet, canOverride = true) }
.flatMap { userWalletsListManager.selectWallet(userWallet.walletId) }
.doOnFailure { error ->
store.dispatchOnMain(SaveWalletAction.Save.Error(error))
}
@ -93,21 +105,29 @@ internal class SaveWalletMiddleware {
preferencesStorage.shouldSaveAccessCodes = isFirstSavedWallet ||
preferencesStorage.shouldSaveAccessCodes
val isSavedWalletSelected =
userWalletsListManager.selectedUserWalletSync?.walletId == userWallet.walletId
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = preferencesStorage.shouldSaveAccessCodes &&
userWallet.hasAccessCode,
)
store.dispatchOnMain(SaveWalletAction.Save.Success)
if (isSavedWalletSelected) {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(userWallet)
} else {
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.WalletSelector))
}
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(userWallet)
}
}
}
private fun dismiss(state: SaveWalletState) {
if (state.backupInfo != null) {
// TODO: Remove after onboarding refactoring
Analytics.send(Onboarding.EnableBiometrics(AnalyticsParam.OnOffState.Off))
} else {
Analytics.send(MainScreen.EnableBiometrics(AnalyticsParam.OnOffState.Off))
}
}
private fun saveWalletWasShown() {
preferencesStorage.shouldShowSaveUserWalletScreen = false
}
@ -118,13 +138,10 @@ internal class SaveWalletMiddleware {
): CompletionResult<Unit> {
return when {
accessCode != null -> {
tangemSdkManager.unlockBiometricKeys()
.flatMap {
tangemSdkManager.saveAccessCode(
accessCode = accessCode,
cardsIds = cardsInWallet,
)
}
tangemSdkManager.saveAccessCode(
accessCode = accessCode,
cardsIds = cardsInWallet,
)
}
else -> {
CompletionResult.Success(Unit)

View file

@ -5,12 +5,10 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.fragment.app.Fragment
import androidx.transition.TransitionInflater
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.accompanist.appcompattheme.AppCompatTheme
@ -22,6 +20,8 @@ import com.tangem.tap.common.extensions.dispatchNotification
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.getString
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.BaseFragment
import com.tangem.tap.features.addBackPressHandler
import com.tangem.tap.features.tokens.redux.ContractAddress
import com.tangem.tap.features.tokens.redux.TokenWithBlockchain
import com.tangem.tap.features.tokens.redux.TokensAction
@ -40,8 +40,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.rekotlin.StoreSubscriber
class AddTokensFragment : Fragment(R.layout.fragment_add_tokens),
StoreSubscriber<TokensState> {
class AddTokensFragment : BaseFragment(R.layout.fragment_add_tokens), StoreSubscriber<TokensState> {
private val binding: FragmentAddTokensBinding by viewBinding(FragmentAddTokensBinding::bind)
private var tokensState: MutableState<TokensState> = mutableStateOf(store.state.tokensState)
@ -51,33 +50,7 @@ class AddTokensFragment : Fragment(R.layout.fragment_add_tokens),
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
Analytics.send(ManageTokens.ScreenOpened())
activity?.onBackPressedDispatcher?.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo())
store.dispatch(TokensAction.ResetState)
}
},
)
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(R.transition.slide_right)
exitTransition = inflater.inflateTransition(R.transition.fade)
}
override fun onStart() {
super.onStart()
store.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
oldState.tokensState == newState.tokensState
}.select { it.tokensState }
}
}
override fun onStop() {
super.onStop()
store.unsubscribe(this)
addBackPressHandler(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(binding) {
@ -96,11 +69,7 @@ class AddTokensFragment : Fragment(R.layout.fragment_add_tokens),
}
val onLoadMore = {
store.dispatch(
TokensAction.LoadMore(
scanResponse = store.state.globalState.scanResponse,
),
)
store.dispatch(TokensAction.LoadMore(scanResponse = store.state.globalState.scanResponse))
}
cvCurrencies.setContent {
@ -115,24 +84,23 @@ class AddTokensFragment : Fragment(R.layout.fragment_add_tokens),
}
}
override fun newState(state: TokensState) {
if (activity == null || view == null) return
val toolbarTitle =
if (state.allowToAdd) R.string.main_manage_tokens else R.string.search_tokens_title
tokensState.value = state
binding.toolbar.title = getString(toolbarTitle)
override fun onStart() {
super.onStart()
store.subscribe(this) { state ->
state
.skipRepeats { oldState, newState -> oldState.tokensState == newState.tokensState }
.select { it.tokensState }
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_search -> true
R.id.menu_navigate_add_custom_token -> {
Analytics.send(ManageTokens.ButtonCustomToken())
store.dispatch(TokensAction.PrepareAndNavigateToAddCustomToken)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onStop() {
super.onStop()
store.unsubscribe(this)
}
override fun onDestroy() {
store.dispatch(TokensAction.ResetState)
super.onDestroy()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -156,25 +124,50 @@ class AddTokensFragment : Fragment(R.layout.fragment_add_tokens),
return super.onCreateOptionsMenu(menu, inflater)
}
override fun onDestroy() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_search -> true
R.id.menu_navigate_add_custom_token -> {
Analytics.send(ManageTokens.ButtonCustomToken())
store.dispatch(TokensAction.PrepareAndNavigateToAddCustomToken)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun configureTransitions() {
super.configureTransitions()
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(R.transition.slide_right)
exitTransition = inflater.inflateTransition(R.transition.fade)
}
override fun handleOnBackPressed() {
super.handleOnBackPressed()
store.dispatch(NavigationAction.PopBackTo())
store.dispatch(TokensAction.ResetState)
super.onDestroy()
}
override fun newState(state: TokensState) {
if (activity == null || view == null) return
val toolbarTitle =
if (state.allowToAdd) R.string.main_manage_tokens else R.string.search_tokens_title
tokensState.value = state
binding.toolbar.title = getString(toolbarTitle)
}
}
fun SearchView.inputtedTextAsFlow(): Flow<String> = callbackFlow {
val watcher =
setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
val watcher = setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
trySend(newText ?: "")
return false
}
},
)
override fun onQueryTextChange(newText: String?): Boolean {
trySend(newText ?: "")
return false
}
},
)
awaitClose { (watcher) }
}

View file

@ -83,25 +83,21 @@ fun CurrenciesScreen(
Scaffold(
floatingActionButton = {
if (tokensState.value.allowToAdd) SaveChangesButton(isKeyboardOpen) {
onSaveChanges(addedTokensState.value, addedBlockchainsState.value)
if (tokensState.value.allowToAdd) {
SaveChangesButton(isKeyboardOpen) {
onSaveChanges(addedTokensState.value, addedBlockchainsState.value)
}
}
},
floatingActionButtonPosition = FabPosition.Center,
) {
AnimatedVisibility(
visible = tokensState.value.loadCoinsState == LoadCoinsState.LOADING,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
CircularProgressIndicator(
color = Color(0xFF1ACE80),
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color(0xFF1ACE80))
}
}
AnimatedVisibility(
@ -148,21 +144,19 @@ private fun toggleBlockchain(
currencySymbol = blockchain.currency,
),
)
} else if (isAddedOnMainScreen) {
store.dispatchDialogShow(
WalletDialog.RemoveWalletDialog(
currencyTitle = blockchain.name,
onOk = {
analyticsCurrencyTypeParam.sendOn()
addedBlockchainsState.removeAndNotify(blockchain)
},
),
)
} else {
if (isAddedOnMainScreen) {
store.dispatchDialogShow(
WalletDialog.RemoveWalletDialog(
currencyTitle = blockchain.name,
onOk = {
analyticsCurrencyTypeParam.sendOn()
addedBlockchainsState.removeAndNotify(blockchain)
},
),
)
} else {
analyticsCurrencyTypeParam.sendOff()
addedBlockchainsState.removeAndNotify(blockchain)
}
analyticsCurrencyTypeParam.sendOff()
addedBlockchainsState.removeAndNotify(blockchain)
}
} else {
analyticsCurrencyTypeParam.sendOn()

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.domain.tokens.Contract
import com.tangem.tap.domain.tokens.Currency
import com.tangem.tap.features.tokens.redux.ContractAddress
import com.tangem.tap.features.tokens.redux.TokenWithBlockchain
@ -30,22 +31,20 @@ fun CurrencyExpandedContent(
exit = fadeOut() + shrinkVertically(),
) {
val blockchains = currency.contracts.map { it.blockchain }
Column(
modifier = Modifier
.fillMaxWidth(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
blockchains.mapIndexed { index, blockchain ->
val contract = currency.contracts.firstOrNull { it.blockchain == blockchain }
val currencyContract = currency.contracts
.firstOrNull { it.blockchain == blockchain }
?: return@mapIndexed
val added = if (contract.address != null) {
addedTokens.map { it.token.contractAddress }.contains(contract.address)
val added = if (currencyContract.address != null) {
addedTokens.any(currencyContract::isInclude)
} else {
addedBlockchains.contains(blockchain)
}
NetworkItem(
currency = currency,
contract = contract,
contract = currencyContract,
blockchain = blockchain,
allowToAdd = allowToAdd,
added = added,
@ -57,4 +56,7 @@ fun CurrencyExpandedContent(
}
}
}
}
}
private fun Contract.isInclude(addedToken: TokenWithBlockchain): Boolean =
address == addedToken.token.contractAddress && blockchain == addedToken.blockchain

View file

@ -8,6 +8,7 @@ import com.tangem.common.extensions.guard
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.events.AnalyticsParam
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
@ -181,6 +182,7 @@ class MultiWalletMiddleware {
selectedWallet: UserWallet,
state: WalletState?,
) = scope.launch {
Analytics.send(MainScreen.CardWasScanned())
ScanCardProcessor.scan(
cardId = selectedWallet.cardId,
additionalBlockchainsToDerive = state?.missingDerivations?.map { it.blockchain },

View file

@ -12,6 +12,7 @@ import com.tangem.domain.common.extensions.withMainContext
import com.tangem.operations.attestation.Attestation
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.events.MainScreen
import com.tangem.tap.common.analytics.events.Token
import com.tangem.tap.common.extensions.copyToClipboard
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification
@ -349,9 +350,11 @@ class WalletMiddleware {
private fun changeWallet() {
when {
userWalletsListManager.hasSavedUserWallets -> {
Analytics.send(MainScreen.ButtonMyWallets())
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.WalletSelector))
}
else -> {
Analytics.send(MainScreen.ButtonScanCard())
store.dispatch(WalletAction.Scan)
}
}

View file

@ -51,7 +51,6 @@ class MultiWalletView : WalletView() {
btnAddToken.show()
}
override fun onViewCreated() {
setupWalletsRecyclerView()
}
@ -69,7 +68,7 @@ class MultiWalletView : WalletView() {
val fragment = fragment ?: return
val binding = binding ?: return
handleTotalBalance(binding, state.totalBalance, state.state)
handleTotalBalance(binding, state.totalBalance, state.state, state.walletsData.size)
handleBackupWarning(binding, state.showBackupWarning)
handleRescanWarning(binding, state.missingDerivations.isNotEmpty())
setupWalletCardNumber(binding, state.walletCardsCount)
@ -137,29 +136,34 @@ class MultiWalletView : WalletView() {
binding: FragmentWalletBinding,
totalBalance: TotalBalance?,
progressState: ProgressState,
walletsCount: Int,
) = with(binding.lCardTotalBalance) {
if (totalBalance == null) {
veilBalance.animateVisibility(show = true)
root.isVisible = progressState == ProgressState.Loading
if (walletsCount == 0) {
root.isVisible = false
} else {
root.isVisible = true
if (totalBalance == null) {
veilBalance.animateVisibility(show = true)
root.isVisible = progressState == ProgressState.Loading
} else {
root.isVisible = true
// Skip changes when on refreshing state
if (totalBalance.state == ProgressState.Refreshing || progressState == ProgressState.Refreshing) {
return@with
}
// Skip changes when on refreshing state
if (totalBalance.state == ProgressState.Refreshing || progressState == ProgressState.Refreshing) {
return@with
}
veilBalance.animateVisibility(show = totalBalance.state == ProgressState.Loading)
tvBalance.animateVisibility(show = totalBalance.state != ProgressState.Loading)
tvProcessing.animateVisibility(show = totalBalance.state == ProgressState.Error)
veilBalance.animateVisibility(show = totalBalance.state == ProgressState.Loading)
tvBalance.animateVisibility(show = totalBalance.state != ProgressState.Loading)
tvProcessing.animateVisibility(show = totalBalance.state == ProgressState.Error)
tvBalance.text = totalBalance.fiatAmount.formatAmountAsSpannedString(
currencySymbol = totalBalance.fiatCurrency.symbol,
)
tvCurrencyName.text = totalBalance.fiatCurrency.code
tvBalance.text = totalBalance.fiatAmount.formatAmountAsSpannedString(
currencySymbol = totalBalance.fiatCurrency.symbol,
)
tvCurrencyName.text = totalBalance.fiatCurrency.code
tvCurrencyName.setOnClickListener {
store.dispatch(WalletAction.AppCurrencyAction.ChooseAppCurrency)
tvCurrencyName.setOnClickListener {
store.dispatch(WalletAction.AppCurrencyAction.ChooseAppCurrency)
}
}
}
}

View file

@ -8,15 +8,16 @@ data class UserWalletModel(
val artworkUrl: String,
val type: Type,
val fiatBalance: TotalFiatBalance,
val isLocked: Boolean,
) {
sealed interface Type {
data class SingleCurrency(
val blockchainName: String? = null,
val blockchainName: String,
) : Type
data class MultiCurrency(
val cardsInWallet: Int,
val tokensCount: Int = 0,
val tokensCount: Int,
) : Type
}
}

View file

@ -37,6 +37,10 @@ internal sealed interface WalletSelectorAction : Action {
val walletId: String,
) : WalletSelectorAction
data class UnlockWalletWithCard(
val walletId: String,
) : WalletSelectorAction
data class RenameWallet(
val walletId: String,
val newName: String,
@ -56,5 +60,6 @@ internal sealed interface WalletSelectorAction : Action {
) : WalletSelectorAction
data class HandleError(val error: TangemError) : WalletSelectorAction
object CloseError : WalletSelectorAction
}

View file

@ -7,6 +7,8 @@ import com.tangem.common.flatMap
import com.tangem.common.map
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.events.MyWallets
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
@ -24,7 +26,6 @@ import com.tangem.tap.tangemSdkManager
import com.tangem.tap.totalFiatBalanceCalculator
import com.tangem.tap.userWalletsListManager
import com.tangem.tap.walletStoresManager
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.rekotlin.Middleware
import timber.log.Timber
@ -60,6 +61,9 @@ internal class WalletSelectorMiddleware {
is WalletSelectorAction.SelectWallet -> {
selectWallet(action.walletId)
}
is WalletSelectorAction.UnlockWalletWithCard -> {
unlockWalletWithCard(action.walletId)
}
is WalletSelectorAction.RemoveWallets -> {
removeWallets(action.walletIdsToRemove, state)
}
@ -105,6 +109,8 @@ internal class WalletSelectorMiddleware {
}
private fun unlockWalletsWithBiometry() {
Analytics.send(MyWallets.Button.UnlockWithBiometrics)
scope.launch {
userWalletsListManager.unlockWithBiometry()
.doOnFailure { error ->
@ -117,6 +123,8 @@ internal class WalletSelectorMiddleware {
}
private fun addWallet() = scope.launch {
Analytics.send(MyWallets.Button.ScanNewCard)
scanCardInternal { scanResponse ->
val userWallet = UserWalletBuilder(scanResponse).build()
@ -125,15 +133,13 @@ internal class WalletSelectorMiddleware {
store.dispatchOnMain(WalletSelectorAction.AddWallet.Error(error))
}
.doOnSuccess {
val selectedWallet = userWalletsListManager.selectedUserWallet.first()
val isSavedWalletSelected = userWallet == selectedWallet
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
Analytics.send(MyWallets.CardWasScanned)
if (isSavedWalletSelected) {
updateAccessCodeRequestPolicy(selectedWallet)
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(selectedWallet)
}
userWalletsListManager.selectWallet(userWallet.walletId)
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
updateAccessCodeRequestPolicy(userWallet)
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(userWallet)
}
}
}
@ -152,7 +158,43 @@ internal class WalletSelectorMiddleware {
}
}
private fun unlockWalletWithCard(id: String) {
scope.launch {
userWalletsListManager.get(UserWalletId(id))
.flatMap { userWallet ->
updateUserWalletWithScannedCard(userWallet)
}
.flatMap { updatedUserWallet ->
unlockUserWallet(updatedUserWallet)
}
.doOnFailure { error ->
store.dispatchOnMain(WalletSelectorAction.HandleError(error))
}
}
}
private suspend fun updateUserWalletWithScannedCard(userWallet: UserWallet): CompletionResult<UserWallet> {
return tangemSdkManager.scanCard(userWallet.cardId)
.map { scannedCard ->
userWallet.copy(
scanResponse = userWallet.scanResponse.copy(
card = scannedCard,
),
)
}
}
private suspend fun unlockUserWallet(userWallet: UserWallet): CompletionResult<Unit> {
return userWalletsListManager.unlockWithCard(userWallet)
.doOnSuccess {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(userWallet)
}
}
private fun removeWallets(walletIdsToRemove: List<String>, state: WalletSelectorState) {
Analytics.send(MyWallets.Button.DeleteWalletTapped)
scope.launch {
when (walletIdsToRemove.size) {
state.wallets.size -> clearUserWallets()
@ -165,6 +207,8 @@ internal class WalletSelectorMiddleware {
}
private fun renameWallet(walletId: String, newName: String) {
Analytics.send(MyWallets.Button.EditWalletTapped)
scope.launch {
userWalletsListManager.get(walletId = UserWalletId(walletId))
.map { it.copy(name = newName) }
@ -221,7 +265,7 @@ internal class WalletSelectorMiddleware {
private fun updateAccessCodeRequestPolicy(userWallet: UserWallet) {
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = preferencesStorage.shouldSaveAccessCodes &&
userWallet.scanResponse.card.isAccessCodeSet,
userWallet.hasAccessCode,
)
}
@ -233,13 +277,7 @@ internal class WalletSelectorMiddleware {
is UserWalletModel.Type.MultiCurrency -> type.copy(
tokensCount = walletStores.flatMap { it.walletsData }.size,
)
is UserWalletModel.Type.SingleCurrency -> type.copy(
blockchainName = walletStores
.firstOrNull()
?.blockchainNetwork
?.blockchain
?.fullName,
)
is UserWalletModel.Type.SingleCurrency -> type
},
fiatBalance = totalFiatBalanceCalculator.calculate(
prevAmount = fiatBalance.amount,

View file

@ -56,6 +56,7 @@ internal object WalletSelectorReducer {
)
is WalletSelectorAction.WalletStoresChanged,
is WalletSelectorAction.SelectWallet,
is WalletSelectorAction.UnlockWalletWithCard,
is WalletSelectorAction.RemoveWallets,
is WalletSelectorAction.RenameWallet,
-> state
@ -66,10 +67,14 @@ internal object WalletSelectorReducer {
return this.map { userWallet ->
prevWallets
.find { it.id == userWallet.walletId.stringValue }
?.copy(
name = userWallet.name,
artworkUrl = userWallet.artworkUrl,
)
?.let {
it.copy(
name = userWallet.name,
artworkUrl = userWallet.artworkUrl,
isLocked = userWallet.isLocked,
type = userWallet.getType(prevType = it.type),
)
}
?: with(userWallet) {
UserWalletModel(
id = walletId.stringValue,
@ -77,6 +82,7 @@ internal object WalletSelectorReducer {
artworkUrl = artworkUrl,
type = getType(),
fiatBalance = TotalFiatBalance.Loading,
isLocked = isLocked,
)
}
}
@ -90,15 +96,18 @@ internal object WalletSelectorReducer {
}
}
private fun UserWallet.getType(): UserWalletModel.Type {
private fun UserWallet.getType(prevType: UserWalletModel.Type? = null): UserWalletModel.Type {
return if (scanResponse.card.isMultiwalletAllowed) {
UserWalletModel.Type.MultiCurrency(
cardsInWallet = (scanResponse.card.backupStatus as? CardDTO.BackupStatus.Active)
?.cardCount?.inc()
?: 1,
tokensCount = (prevType as? UserWalletModel.Type.MultiCurrency)?.tokensCount ?: 0,
)
} else {
UserWalletModel.Type.SingleCurrency()
UserWalletModel.Type.SingleCurrency(
blockchainName = scanResponse.getBlockchain().fullName,
)
}
}
}

View file

@ -60,6 +60,7 @@ private fun List<UserWalletModel>.toUiModels(
name = name,
imageUrl = artworkUrl,
balance = balance,
isLocked = isLocked,
cardsInWallet = type.cardsInWallet,
tokensCount = type.tokensCount,
)
@ -68,6 +69,7 @@ private fun List<UserWalletModel>.toUiModels(
name = name,
imageUrl = artworkUrl,
balance = balance,
isLocked = isLocked,
tokenName = type.blockchainName ?: "",
)
}

View file

@ -1,5 +1,7 @@
package com.tangem.tap.features.walletSelector.ui
import android.app.Dialog
import android.os.Bundle
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -14,6 +16,8 @@ import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.fragment.app.viewModels
import com.tangem.core.ui.fragments.ComposeBottomSheetFragment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.analytics.Analytics
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.RenameWalletDialogContent
import com.tangem.tap.features.walletSelector.ui.components.WalletSelectorScreenContent
@ -22,6 +26,12 @@ import com.tangem.tap.features.walletSelector.ui.model.RenameWalletDialog
internal class WalletSelectorBottomSheetFragment : ComposeBottomSheetFragment<WalletSelectorScreenState>() {
private val viewModel by viewModels<WalletSelectorViewModel>()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
Analytics.send(MyWallets.MyWalletsScreenOpened)
return super.onCreateDialog(savedInstanceState)
}
@Composable
override fun provideState(): State<WalletSelectorScreenState> {
return viewModel.state.collectAsState()

View file

@ -37,7 +37,9 @@ internal class WalletSelectorViewModel : ViewModel(), StoreSubscriber<WalletSele
fun walletClicked(walletId: String) = with(state.value) {
when {
isLocked -> Unit
isLocked -> {
store.dispatch(WalletSelectorAction.UnlockWalletWithCard(walletId))
}
editingWalletsIds.isNotEmpty() && !editingWalletsIds.contains(walletId) -> {
editWallet(walletId)
}

View file

@ -14,6 +14,7 @@ internal object MockData {
),
name = "Wallet",
imageUrl = "https://app.tangem.com/cards/card_default.png",
isLocked = false,
tokensCount = 12,
cardsInWallet = 3,
)
@ -26,6 +27,7 @@ internal object MockData {
),
name = "Wallet",
imageUrl = "https://app.tangem.com/cards/card_default.png",
isLocked = false,
tokenName = "Ethereum",
)

View file

@ -72,7 +72,6 @@ internal fun WalletSelectorScreenContent(
singleCurrencyWallets = state.singleCurrencyWallets,
selectedWalletId = state.selectedWalletId,
checkedWalletIds = state.editingWalletsIds,
isLocked = state.isLocked,
onWalletClick = onWalletClick,
onWalletLongClick = onWalletLongClick,
)
@ -141,7 +140,6 @@ private fun WalletsList(
singleCurrencyWallets: List<UserWalletItem>,
selectedWalletId: String?,
checkedWalletIds: List<String>,
isLocked: Boolean,
onWalletClick: (walletId: String) -> Unit,
onWalletLongClick: (walletId: String) -> Unit,
) {
@ -166,7 +164,6 @@ private fun WalletsList(
wallet = wallet,
isSelected = wallet.id == selectedWalletId,
isChecked = wallet.id in checkedWalletIds,
isLocked = isLocked && wallet.id != selectedWalletId,
)
}
}

View file

@ -39,7 +39,6 @@ internal fun WalletItem(
wallet: UserWalletItem,
isSelected: Boolean,
isChecked: Boolean,
isLocked: Boolean,
) {
Row(
modifier = modifier,
@ -59,7 +58,7 @@ internal fun WalletItem(
SpacerW6()
TokensInfo(
modifier = Modifier.weight(weight = .4f),
isLocked = isLocked,
isLocked = wallet.isLocked,
balance = wallet.balance,
tokensCount = (wallet as? MultiCurrencyUserWalletItem)?.tokensCount,
)

View file

@ -1,15 +1,14 @@
package com.tangem.tap.features.walletSelector.ui.model
import androidx.compose.runtime.Immutable
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
@Immutable
internal sealed interface UserWalletItem {
val id: String
val name: String
val imageUrl: String
val balance: Balance
val isLocked: Boolean
val headerText: TextReference
get() = when (this) {
@ -17,7 +16,6 @@ internal sealed interface UserWalletItem {
is SingleCurrencyUserWalletItem -> TextReference.Res(R.string.user_wallet_list_single_header)
}
@Immutable
data class Balance(
val amount: String,
val isLoading: Boolean,
@ -29,6 +27,7 @@ internal data class MultiCurrencyUserWalletItem(
override val name: String,
override val imageUrl: String,
override val balance: UserWalletItem.Balance,
override val isLocked: Boolean,
val tokensCount: Int,
val cardsInWallet: Int,
) : UserWalletItem
@ -38,5 +37,6 @@ internal data class SingleCurrencyUserWalletItem(
override val name: String,
override val imageUrl: String,
override val balance: UserWalletItem.Balance,
override val isLocked: Boolean,
val tokenName: String,
) : UserWalletItem

View file

@ -61,7 +61,7 @@ internal class WelcomeMiddleware {
if (selectedUserWallet != null) {
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = preferencesStorage.shouldSaveAccessCodes &&
selectedUserWallet.scanResponse.card.isAccessCodeSet,
selectedUserWallet.hasAccessCode,
)
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet))
@ -78,14 +78,12 @@ internal class WelcomeMiddleware {
scanCardInternal { scanResponse ->
val userWallet = UserWalletBuilder(scanResponse).build()
tangemSdkManager.setAccessCodeRequestPolicy(useBiometricsForAccessCode = false)
userWalletsListManager.unlockWithCard(userWallet)
.doOnFailure { error ->
store.dispatchOnMain(WelcomeAction.ProceedWithCard.Error(error))
}
.doOnSuccess {
tangemSdkManager.setAccessCodeRequestPolicy(
useBiometricsForAccessCode = false,
)
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet))
store.dispatchOnMain(WelcomeAction.ProceedWithCard.Success)
store.onUserWalletSelected(userWallet)

View file

@ -59,7 +59,8 @@ object Versions {
// region Tangem
const val tangemBlockchainSdk = "develop-141"
const val tangemCardSgk = "develop-171"
const val tangemCardSgk = "develop-173"
// endregion Tangem
// region Testing

View file

@ -14,7 +14,7 @@ interface TangemTechApi {
@GET("coins")
suspend fun coins(
@Query("contractAddress") contractAddress: String? = null,
@Query("exchangeable") exchangeable: Boolean? = false,
@Query("exchangeable") exchangeable: Boolean? = null,
@Query("networkIds") networkIds: String? = null,
@Query("active") active: Boolean? = null,
@Query("searchText") searchText: String? = null,