Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-17 14:39:32 +04:00
parent 6b75b48f1e
commit 8e6eca1b4c
23 changed files with 327 additions and 216 deletions

View file

@ -1,55 +0,0 @@
package com.tangem.tap.common.extensions
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchainsdk.utils.amountToCreateAccount
import com.tangem.common.services.Result
import com.tangem.tap.common.TestActions
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.delay
/**
[REDACTED_AUTHOR]
*/
@Deprecated(
message = "Use WalletStoresManager.fetch({userWalletId}, refresh = true) (to update all user wallet tokens)" +
"or WalletCurrenciesManager.update(...) (to update only one user wallet blockchain and its tokens) instead",
)
@Suppress("MagicNumber")
suspend fun WalletManager.safeUpdate(isDemoCard: Boolean): Result<Wallet> = try {
if (isDemoCard || TestActions.isTestAmountInjectionForWalletManagerEnabled) {
delay(500)
TestActions.isTestAmountInjectionForWalletManagerEnabled = false
Result.Success(wallet)
} else {
update()
Result.Success(wallet)
}
} catch (exception: Exception) {
TangemLogger.e("Error", exception)
val networkConnectionManager = store.inject(DaggerGraphState::networkConnectionManager)
if (!networkConnectionManager.isOnline) {
Result.Failure(TapError.NoInternetConnection())
} else {
val blockchain = wallet.blockchain
val amountToCreateAccount = blockchain.amountToCreateAccount(this, wallet.getFirstToken())
if (exception is BlockchainSdkError.AccountNotFound && amountToCreateAccount != null) {
Result.Failure(TapError.WalletManager.NoAccountError(amountToCreateAccount.toString()))
} else {
when (exception) {
is BlockchainSdkError -> Result.Failure(exception)
else -> {
val message = exception.cause?.localizedMessage ?: "Unknown error"
Result.Failure(TapError.WalletManager.InternalError(message))
}
}
}
}
}

View file

@ -4,7 +4,6 @@ import com.tangem.datasource.api.moonpay.MoonPayApi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -14,8 +13,7 @@ import com.tangem.tap.domain.scanCard.repository.DefaultScanCardRepository
import com.tangem.tap.network.exchangeServices.DefaultRampManager
import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -45,13 +43,13 @@ internal object ActivityModule {
@Provides
@Singleton
fun provideDefaultRampManager(
appStateHolder: AppStateHolder,
sellService: SellService,
expressServiceFetcher: ExpressServiceFetcher,
currenciesRepository: CurrenciesRepository,
dispatchers: CoroutineDispatcherProvider,
): RampStateManager {
return DefaultRampManager(
sellService = Provider { requireNotNull(appStateHolder.sellService) },
sellService = sellService,
expressServiceFetcher = expressServiceFetcher,
currenciesRepository = currenciesRepository,
dispatchers = dispatchers,

View file

@ -2,8 +2,8 @@ package com.tangem.tap.di.domain
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.transaction.WalletAddressServiceRepository
import com.tangem.domain.transaction.usecase.ParseSharedAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
@ -24,6 +24,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.tap.domain.DefaultUserWalletSelectedHandler
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -151,14 +152,14 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun providesSelectWalletUseCase(
userWalletsListRepository: UserWalletsListRepository,
reduxStateHolder: ReduxStateHolder,
): SelectWalletUseCase {
return SelectWalletUseCase(
userWalletsListRepository = userWalletsListRepository,
reduxStateHolder = reduxStateHolder,
)
fun providesSelectWalletUseCase(userWalletsListRepository: UserWalletsListRepository): SelectWalletUseCase {
return SelectWalletUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@Singleton
fun providesUserWalletSelectedHandler(handler: DefaultUserWalletSelectedHandler): UserWalletSelectedHandler {
return handler
}
@Provides

View file

@ -0,0 +1,61 @@
package com.tangem.tap.domain
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveInAndJoin
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [UserWalletSelectedHandler].
*
* Runs three side effects on every selection: updates the analytics tracking context, updates the
* Tangem SDK displayed card-id numbers count (cold wallets only), and recomputes the access code
* request policy (cold wallets only). Hot wallets trigger only the tracking-context update.
*
* Invocations are serialised via [JobHolder]: if a new [invoke] arrives while the previous one is
* still running, the previous load is cancelled and the new one replaces it. The method suspends
* until the newly launched load completes.
*/
@Singleton
internal class DefaultUserWalletSelectedHandler @Inject constructor(
private val trackingContextProxy: TrackingContextProxy,
private val tangemSdkManager: TangemSdkManager,
private val settingsRepository: SettingsRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val appCoroutineScope: AppCoroutineScope,
) : UserWalletSelectedHandler {
private val loadUserWalletDataJob: JobHolder = JobHolder()
override suspend fun invoke(userWallet: UserWallet) {
appCoroutineScope.launch { loadUserWalletData(userWallet) }
.saveInAndJoin(loadUserWalletDataJob)
}
private suspend fun loadUserWalletData(userWallet: UserWallet) {
trackingContextProxy.setContext(userWallet)
if (userWallet is UserWallet.Cold) {
val scanResponse = userWallet.scanResponse
tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse)
updateAccessCodeRequestPolicy(scanResponse)
}
}
private suspend fun updateAccessCodeRequestPolicy(scanResponse: ScanResponse) {
val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes()
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = shouldSaveAccessCodes && scanResponse.card.isAccessCodeSet,
)
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.common.services.secure.SecureStorage
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.scan.serialization.*
@ -32,6 +33,7 @@ import com.tangem.tap.tangemSdkManager
import com.tangem.utils.Provider
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Lazy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -56,6 +58,7 @@ internal object UserWalletsListRepositoryModule {
analyticsEventHandler: AnalyticsEventHandler,
hotWalletRepository: HotWalletRepository,
mobileWalletPromoRepository: MobileWalletPromoRepository,
userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
): UserWalletsListRepository {
val moshi = buildMoshi()
val secureStorage = buildSecureStorage(applicationContext = applicationContext)
@ -107,6 +110,7 @@ internal object UserWalletsListRepositoryModule {
analyticsEventHandler = analyticsEventHandler,
hotWalletRepository = hotWalletRepository,
mobileWalletPromoRepository = mobileWalletPromoRepository,
userWalletSelectedHandler = userWalletSelectedHandler,
)
}

View file

@ -13,6 +13,7 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletTransformAction
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
@ -35,6 +36,7 @@ import com.tangem.tap.domain.userWalletList.utils.*
import com.tangem.utils.Provider
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.coroutines.runSuspendCatching
import dagger.Lazy
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.coroutines.NonCancellable
@ -60,6 +62,7 @@ internal class DefaultUserWalletsListRepository(
private val analyticsEventHandler: AnalyticsEventHandler,
private val hotWalletRepository: HotWalletRepository,
private val mobileWalletPromoRepository: MobileWalletPromoRepository,
private val userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
) : UserWalletsListRepository {
override val userWallets = MutableStateFlow<List<UserWallet>?>(null)
@ -88,15 +91,13 @@ internal class DefaultUserWalletsListRepository(
.map { wallets.updateWith(it) }
}
.doOnSuccess { loadedWallets ->
userWallets.update { _ ->
val selectedUserWalletId = selectedUserWalletRepository.get()
selectedUserWallet.value = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId }
?: loadedWallets.firstOrNull()?.also {
selectedUserWalletRepository.set(it.walletId)
}
loadedWallets
}
val selectedUserWalletId = selectedUserWalletRepository.get()
val initialSelection = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId }
?: loadedWallets.firstOrNull()?.also {
selectedUserWalletRepository.set(it.walletId)
}
userWallets.value = loadedWallets
setSelectedUserWallet(initialSelection)
}
}
}
@ -117,7 +118,7 @@ internal class DefaultUserWalletsListRepository(
val userWallet = userWallets.value?.find { it.walletId == userWalletId }
?: raise(SelectWalletError.UnableToSelectUserWallet)
selectedUserWalletRepository.set(userWalletId)
selectedUserWallet.value = userWallet
setSelectedUserWallet(userWallet)
userWallet
}
@ -160,7 +161,7 @@ internal class DefaultUserWalletsListRepository(
// update the selectedUserWallet state if it is the only wallet
if (userWallets.value?.size == 1) {
selectedUserWalletRepository.set(userWallet.walletId)
selectedUserWallet.value = userWallet
setSelectedUserWallet(userWallet)
}
userWallet
@ -223,20 +224,24 @@ internal class DefaultUserWalletsListRepository(
removeHotWalletsFromSDKAndRepos(userWalletIds)
userWallets.update { currentWallets ->
val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() }
selectedUserWallet.update { currentSelected ->
if (currentSelected == null) return@update null
val newSelected = updatedWallets?.findAvailableUserWallet(
currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0,
)
if (newSelected == null) {
onAllWalletsDeleted()
}
selectedUserWalletRepository.set(newSelected?.walletId)
newSelected
val currentWallets = userWallets.value
val currentSelected = selectedUserWallet.value
val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() }
val newSelected = if (currentSelected == null) {
null
} else {
updatedWallets?.findAvailableUserWallet(
currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0,
)
}
userWallets.value = updatedWallets
if (currentSelected != null) {
if (newSelected == null) {
onAllWalletsDeleted()
}
updatedWallets
selectedUserWalletRepository.set(newSelected?.walletId)
setSelectedUserWallet(newSelected)
}
}
@ -523,6 +528,18 @@ internal class DefaultUserWalletsListRepository(
return tangemSdkManagerProvider.invoke().canUseBiometry && isBiometricAuthenticationUsed
}
/**
* Writes [userWallet] into [selectedUserWallet] and invokes [userWalletSelectedHandler] when
* the selected [UserWalletId] actually changes. Same-id refreshes stay silent.
*/
private suspend fun setSelectedUserWallet(userWallet: UserWallet?) {
val previousId = selectedUserWallet.value?.walletId
selectedUserWallet.value = userWallet
if (userWallet != null && previousId != userWallet.walletId) {
userWalletSelectedHandler.get().invoke(userWallet)
}
}
private fun updateWallets(block: (List<UserWallet>?) -> List<UserWallet>?) {
userWallets.update { wallets ->
val updated = block(wallets)

View file

@ -2,7 +2,6 @@ package com.tangem.tap.features.demo
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.redux.AppState
object DemoHelper {
val config = DemoConfig
@ -12,15 +11,4 @@ object DemoHelper {
fun isTestDemoCard(scanResponse: ScanResponse): Boolean = config.isTestDemoCardId(scanResponse.card.cardId)
fun isDemoCardId(cardId: String): Boolean = config.isDemoCardId(cardId)
fun tryHandle(appState: () -> AppState?): Boolean {
val scanResponse = getScanResponse(appState) ?: return false
if (!scanResponse.isDemoCard()) return false
return false
}
private fun getScanResponse(appState: () -> AppState?): ScanResponse? {
return appState()?.globalState?.scanResponse
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.tap.features.demo
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
/**
[REDACTED_AUTHOR]
*/
fun ScanResponse.isDemoCard(): Boolean = DemoHelper.isDemoCardId(card.cardId)
fun CardDTO.isDemoCard(): Boolean = DemoHelper.isDemoCardId(cardId)

View file

@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.ui.cardsettings.coderecovery.api.AccessCodeRecoveryComponent
import com.tangem.tap.features.details.ui.cardsettings.coderecovery.model.AccessCodeRecoveryModel
import com.tangem.tap.store
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -19,6 +17,7 @@ import dagger.assisted.AssistedInject
internal class DefaultAccessCodeRecoveryComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
private val appRouter: AppRouter,
) : AccessCodeRecoveryComponent, AppComponentContext by appComponentContext {
private val model: AccessCodeRecoveryModel = getOrCreateModel()
@ -29,7 +28,7 @@ internal class DefaultAccessCodeRecoveryComponent @AssistedInject constructor(
AccessCodeRecoveryScreen(
state = state,
onBackClick = { store.dispatchNavigationAction(AppRouter::pop) },
onBackClick = { appRouter.pop() },
)
}

View file

@ -10,11 +10,9 @@ import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.ui.cardsettings.coderecovery.AccessCodeRecoveryScreenState
import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor
import com.tangem.tap.features.details.ui.common.utils.isAccessCodeRecoveryEnabled
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -27,6 +25,7 @@ internal class AccessCodeRecoveryModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val tangemSdkManager: TangemSdkManager,
private val cardSettingsInteractor: CardSettingsInteractor,
private val appRouter: AppRouter,
) : Model() {
private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value
@ -73,7 +72,7 @@ internal class AccessCodeRecoveryModel @Inject constructor(
)
}
store.dispatchNavigationAction(AppRouter::pop)
appRouter.pop()
}
}

View file

@ -27,13 +27,11 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.ui.cardsettings.CardInfo
import com.tangem.tap.features.details.ui.cardsettings.CardSettingsScreenState
import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent
import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor
import com.tangem.tap.features.details.ui.common.utils.*
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addIf
import com.tangem.utils.logging.TangemLogger
@ -56,6 +54,7 @@ internal class CardSettingsModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val onboardingRepository: OnboardingRepository,
private val uiMessageSender: UiMessageSender,
private val appRouter: AppRouter,
) : Model() {
private val params = paramsContainer.require<CardSettingsComponent.Params>()
@ -188,12 +187,10 @@ internal class CardSettingsModel @Inject constructor(
}
is CardInfo.SecurityMode -> {
Analytics.send(Settings.CardSettings.ButtonChangeSecurityMode())
store.dispatchNavigationAction {
push(route = AppRoute.DetailsSecurity(userWalletId))
}
appRouter.push(route = AppRoute.DetailsSecurity(userWalletId))
}
is CardInfo.AccessCodeRecovery -> {
store.dispatchNavigationAction { push(AppRoute.AccessCodeRecovery) }
appRouter.push(AppRoute.AccessCodeRecovery)
}
else -> {}
}
@ -205,30 +202,26 @@ internal class CardSettingsModel @Inject constructor(
}
if (scanResponse.cardTypesResolver.isTangemTwins()) {
store.dispatchNavigationAction {
push(
AppRoute.Onboarding(
scanResponse = scanResponse,
mode = AppRoute.Onboarding.Mode.RecreateWalletTwin,
),
)
}
appRouter.push(
AppRoute.Onboarding(
scanResponse = scanResponse,
mode = AppRoute.Onboarding.Mode.RecreateWalletTwin,
),
)
} else {
val card = scanResponse.card
modelScope.launch {
val hasTangemPay = onboardingRepository.hasTangemPayInWallet(userWalletId).getOrNull() == true
store.dispatchNavigationAction {
push(
route = AppRoute.ResetToFactory(
userWalletId = userWalletId,
cardId = card.cardId,
isActiveBackupStatus = card.backupStatus?.isActive == true,
backupCardsCount = scanResponse.getBackupCardsCount() ?: 0,
hasTangemPay = hasTangemPay,
),
)
}
appRouter.push(
route = AppRoute.ResetToFactory(
userWalletId = userWalletId,
cardId = card.cardId,
isActiveBackupStatus = card.backupStatus?.isActive == true,
backupCardsCount = scanResponse.getBackupCardsCount() ?: 0,
hasTangemPay = hasTangemPay,
),
)
}
}
}
@ -252,6 +245,6 @@ internal class CardSettingsModel @Inject constructor(
private fun onBackClick() {
cardSettingsInteractor.clear()
store.dispatchNavigationAction(AppRouter::pop)
appRouter.pop()
}
}

View file

@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent
import com.tangem.tap.features.details.ui.resetcard.model.ResetCardModel
import com.tangem.tap.store
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -18,6 +16,7 @@ import dagger.assisted.AssistedInject
internal class DefaultResetCardComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: ResetCardComponent.Params,
private val appRouter: AppRouter,
) : ResetCardComponent, AppComponentContext by appComponentContext {
private val model: ResetCardModel = getOrCreateModel(params)
@ -29,7 +28,7 @@ internal class DefaultResetCardComponent @AssistedInject constructor(
ResetCardScreen(
modifier = modifier,
state = state,
onBackClick = { store.dispatchNavigationAction(AppRouter::pop) },
onBackClick = { appRouter.pop() },
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.details.ui.resetcard.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.utils.popTo
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -18,14 +19,11 @@ import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor
import com.tangem.tap.features.details.ui.common.utils.getResetToFactoryDescription
import com.tangem.tap.features.details.ui.resetcard.ResetCardDialog
import com.tangem.tap.features.details.ui.resetcard.ResetCardScreenState
import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE
import com.tangem.utils.logging.TangemLogger
@ -51,6 +49,7 @@ internal class ResetCardModel @Inject constructor(
private val deleteWalletUseCase: DeleteWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val cardSettingsInteractor: CardSettingsInteractor,
private val appRouter: AppRouter,
) : Model() {
private val params = paramsContainer.require<ResetCardComponent.Params>()
@ -189,19 +188,11 @@ internal class ResetCardModel @Inject constructor(
modelScope.launch {
resetCardUseCase(cardId = primaryCardId, params = currentUserCodeParams).onRight {
deleteSavedAccessCodesUseCase(cardId = primaryCardId)
val hasUserWallets = deleteWalletUseCase(userWalletId = currentUserWalletId).getOrElse { error ->
deleteWalletUseCase(userWalletId = currentUserWalletId).getOrElse { error ->
TangemLogger.e("Unable to delete user wallet: $error")
return@launch
}
if (hasUserWallets) {
val newSelectedWallet = getSelectedWalletSyncUseCase().getOrElse { error ->
error("Failed to get selected wallet: $error")
}
store.onUserWalletSelected(newSelectedWallet)
}
delay(DELAY_SDK_DIALOG_CLOSE)
checkRemainingBackupCards()
@ -270,9 +261,9 @@ internal class ResetCardModel @Inject constructor(
val newSelectedWallet = getSelectedWalletSyncUseCase.invoke().getOrNull()
if (newSelectedWallet != null) {
store.dispatchNavigationAction { popTo<AppRoute.Wallet>() }
appRouter.popTo<AppRoute.Wallet>()
} else {
store.dispatchNavigationAction { replaceAll(AppRoute.Home()) }
appRouter.replaceAll(AppRoute.Home())
}
}

View file

@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent
import com.tangem.tap.features.details.ui.securitymode.model.SecurityModeModel
import com.tangem.tap.store
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -18,6 +16,7 @@ import dagger.assisted.AssistedInject
internal class DefaultSecurityModeComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: SecurityModeComponent.Params,
private val appRouter: AppRouter,
) : SecurityModeComponent, AppComponentContext by appComponentContext {
private val model: SecurityModeModel = getOrCreateModel(params)
@ -29,7 +28,7 @@ internal class DefaultSecurityModeComponent @AssistedInject constructor(
SecurityModeScreen(
modifier = modifier,
state = state,
onBackClick = { store.dispatchNavigationAction(AppRouter::pop) },
onBackClick = { appRouter.pop() },
)
}

View file

@ -13,13 +13,11 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.redux.SecurityOption
import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor
import com.tangem.tap.features.details.ui.common.utils.getAllowedSecurityOptions
import com.tangem.tap.features.details.ui.common.utils.getCurrentSecurityOption
import com.tangem.tap.features.details.ui.securitymode.SecurityModeScreenState
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -34,6 +32,7 @@ internal class SecurityModeModel @Inject constructor(
private val cardSettingsInteractor: CardSettingsInteractor,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsErrorHandler: AnalyticsErrorHandler,
private val appRouter: AppRouter,
) : Model() {
private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value
@ -91,7 +90,7 @@ internal class SecurityModeModel @Inject constructor(
is CompletionResult.Success -> {
analyticsEventHandler.send(Settings.CardSettings.SecurityModeChanged(paramValue))
store.dispatchNavigationAction(AppRouter::pop)
appRouter.pop()
}
is CompletionResult.Failure -> {
val error = result.error
@ -99,7 +98,6 @@ internal class SecurityModeModel @Inject constructor(
analyticsErrorHandler.sendErrorEvent(TangemSdkErrorEvent(error))
}
}
else -> Unit
}
}
}

View file

@ -21,7 +21,6 @@ import com.tangem.domain.balancehiding.BalanceHidingSettings
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.notifications.ClearApplicationIdUseCase
import com.tangem.domain.notifications.GetApplicationIdUseCase
import com.tangem.domain.notifications.SendPushTokenUseCase
@ -37,7 +36,6 @@ import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase
import com.tangem.domain.staking.FetchStakingOptionsUseCase
import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase
import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase
import com.tangem.feature.swap.analytics.StoriesEvents
import com.tangem.security.DeviceSecurityInfoProvider
@ -80,7 +78,6 @@ internal class MainViewModel @Inject constructor(
private val apiConfigsManager: ApiConfigsManager,
private val multiQuoteUpdater: MultiQuoteUpdater,
private val appStateHolder: AppStateHolder,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
private val appRouterConfig: AppRouterConfig,
private val sellService: SellService,
private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider,
@ -143,8 +140,6 @@ internal class MainViewModel @Inject constructor(
launch { fetchUserCountry() }
}
subscribeToSelectedWallet()
// await while initial route stack is initialized
appRouterConfig.initializedState.first { it }
@ -173,20 +168,6 @@ internal class MainViewModel @Inject constructor(
}
}
private fun subscribeToSelectedWallet() {
getSelectedWalletUseCase.invoke()
.mapLeft { emptyFlow<UserWallet>() }
.onRight { wallet ->
wallet.distinctUntilChanged()
.onEach {
// FIXME Do not remove this call without checking implications !!!
appStateHolder.onUserWalletSelected(it)
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
}
}
private suspend fun fetchStakingOptions() {
fetchStakingOptionsUseCase()
.onLeft { TangemLogger.e("Unable to fetch staking options: $it") }

View file

@ -17,7 +17,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import com.tangem.utils.coroutines.runSuspendCatching
@ -26,7 +25,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
internal class DefaultRampManager(
private val sellService: Provider<SellService>,
private val sellService: SellService,
private val expressServiceFetcher: ExpressServiceFetcher,
private val currenciesRepository: CurrenciesRepository,
private val dispatchers: CoroutineDispatcherProvider,
@ -53,7 +52,7 @@ internal class DefaultRampManager(
block = {
val serviceCurrency = CryptoCurrencyConverter.convert(status.currency)
sellService().availableForSell(currency = serviceCurrency)
sellService.availableForSell(currency = serviceCurrency)
},
catch = { raise(ScenarioUnavailabilityReason.NotSupportedBySellService(status.currency.name)) },
)
@ -92,12 +91,12 @@ internal class DefaultRampManager(
}
override fun getSellInitializationStatus(): Flow<SellServiceInitializationStatus> {
return sellService.invoke().initializationStatus
return sellService.initializationStatus
}
override suspend fun fetchSellServiceData() {
runCatching(dispatchers.io) {
sellService.invoke().update()
sellService.update()
}
}

View file

@ -40,18 +40,16 @@ import com.tangem.hot.sdk.android.create
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.common.analytics.events.Onboarding
import com.tangem.tap.features.scanfails.ScanFailsComponent
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.hot.TangemHotSDKProxy
import com.tangem.tap.features.root.RootDetectedWarningComponent
import com.tangem.tap.features.scanfails.ScanFailsComponent
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
import com.tangem.tap.routing.RootContent
import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.component.RoutingComponent.Child
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.tap.routing.utils.ChildFactory
import com.tangem.tap.routing.utils.DeepLinkFactory
import com.tangem.tap.store
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import dagger.assisted.Assisted
@ -270,7 +268,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
private fun checkForUnfinishedBackup() {
if (DemoHelper.tryHandle { store.state }) return
componentScope.launch(dispatchers.main) {
val scanResponse = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch
messageSender.send(unfinishedBackupFoundDialog(scanResponse))

View file

@ -0,0 +1,147 @@
package com.tangem.tap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.sdk.api.TangemSdkManager
import io.mockk.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultUserWalletSelectedHandlerTest {
private val trackingContextProxy = mockk<TrackingContextProxy>(relaxed = true)
private val tangemSdkManager = mockk<TangemSdkManager>(relaxed = true)
private val settingsRepository = mockk<SettingsRepository>()
private val cardSdkConfigRepository = mockk<CardSdkConfigRepository>(relaxed = true)
private val appScope = TestAppCoroutineScope()
private lateinit var handler: DefaultUserWalletSelectedHandler
@BeforeEach
fun setup() {
clearMocks(trackingContextProxy, tangemSdkManager, settingsRepository, cardSdkConfigRepository)
handler = DefaultUserWalletSelectedHandler(
trackingContextProxy = trackingContextProxy,
tangemSdkManager = tangemSdkManager,
settingsRepository = settingsRepository,
cardSdkConfigRepository = cardSdkConfigRepository,
appCoroutineScope = appScope,
)
}
@Test
fun `cold wallet with access code and save-codes enabled applies biometric policy`() = runTest {
val userWallet = coldWalletWith(isAccessCodeSet = true)
coEvery { settingsRepository.shouldSaveAccessCodes() } returns true
handler(userWallet)
verify(exactly = 1) { trackingContextProxy.setContext(userWallet) }
verify(exactly = 1) { tangemSdkManager.changeDisplayedCardIdNumbersCount(userWallet.scanResponse) }
verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) }
}
@Test
fun `cold wallet without access code keeps biometric policy off even when save-codes enabled`() = runTest {
assertThat(capturePolicyFor(shouldSaveAccessCodes = true, isAccessCodeSet = false)).isFalse()
}
@Test
fun `cold wallet with access code keeps biometric policy off when save-codes disabled`() = runTest {
assertThat(capturePolicyFor(shouldSaveAccessCodes = false, isAccessCodeSet = true)).isFalse()
}
@Test
fun `cold wallet without access code and save-codes disabled keeps biometric policy off`() = runTest {
assertThat(capturePolicyFor(shouldSaveAccessCodes = false, isAccessCodeSet = false)).isFalse()
}
@Test
fun `hot wallet only updates tracking context`() = runTest {
val hotWallet = mockk<UserWallet.Hot>()
handler(hotWallet)
verify(exactly = 1) { trackingContextProxy.setContext(hotWallet) }
verify(exactly = 0) { tangemSdkManager.changeDisplayedCardIdNumbersCount(any()) }
coVerify(exactly = 0) { settingsRepository.shouldSaveAccessCodes() }
verify(exactly = 0) { cardSdkConfigRepository.setAccessCodeRequestPolicy(any()) }
}
@Test
fun `consecutive invocations both run policy update`() = runTest {
val firstWallet = coldWalletWith(isAccessCodeSet = true)
val secondWallet = coldWalletWith(isAccessCodeSet = false)
coEvery { settingsRepository.shouldSaveAccessCodes() } returns true
handler(firstWallet)
handler(secondWallet)
verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) }
verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) }
}
@Test
fun `new invocation cancels in-flight job so only latest side effects are applied`() = runTest {
val firstWallet = coldWalletWith(isAccessCodeSet = true)
val secondWallet = coldWalletWith(isAccessCodeSet = false)
val firstCallGate = CompletableDeferred<Boolean>()
var callIndex = 0
coEvery { settingsRepository.shouldSaveAccessCodes() } coAnswers {
callIndex++
if (callIndex == 1) firstCallGate.await() else true
}
val firstHandlerJob = launch { handler(firstWallet) }
runCurrent()
handler(secondWallet)
firstCallGate.complete(true)
firstHandlerJob.join()
verify(exactly = 0) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) }
verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) }
verify(exactly = 1) { trackingContextProxy.setContext(secondWallet) }
}
private suspend fun capturePolicyFor(shouldSaveAccessCodes: Boolean, isAccessCodeSet: Boolean): Boolean {
val userWallet = coldWalletWith(isAccessCodeSet = isAccessCodeSet)
coEvery { settingsRepository.shouldSaveAccessCodes() } returns shouldSaveAccessCodes
val captured = slot<Boolean>()
handler(userWallet)
verify { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = capture(captured)) }
return captured.captured
}
private fun coldWalletWith(isAccessCodeSet: Boolean): UserWallet.Cold {
val baseScanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(maxWalletCount = 2),
derivedKeys = emptyMap(),
)
val scanResponse: ScanResponse = baseScanResponse.copy(
card = baseScanResponse.card.copy(
cardId = if (isAccessCodeSet) "CARD-WITH-CODE" else "CARD-NO-CODE",
isAccessCodeSet = isAccessCodeSet,
),
)
return MockUserWalletFactory.create(scanResponse = scanResponse)
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.common.wallets
import com.tangem.domain.models.wallet.UserWallet
/**
* Handler invoked when a user wallet becomes the active one.
*
* Side effects (analytics tracking context, Tangem SDK display config, access code request policy, etc.)
* follow switch-latest semantics: if a new selection arrives while a previous one is still being processed,
* the in-flight job is cancelled and only the latest selection is applied.
*/
interface UserWalletSelectedHandler {
suspend operator fun invoke(userWallet: UserWallet)
}

View file

@ -5,25 +5,23 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SelectWalletError
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.redux.ReduxStateHolder
/**
* Use case for selecting wallet
* Use case for selecting wallet.
*
* Side effects tied to selection (analytics tracking context, Tangem SDK display config, access
* code request policy) are fired from the repository itself when the selected [UserWalletId]
* changes see the implementation of [UserWalletsListRepository.select].
*
* @property userWalletsListRepository repository for getting list of user wallets
* @property reduxStateHolder redux state holder
*
[REDACTED_AUTHOR]
*/
class SelectWalletUseCase(
private val userWalletsListRepository: UserWalletsListRepository,
private val reduxStateHolder: ReduxStateHolder,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<SelectWalletError, UserWallet> {
return userWalletsListRepository.select(userWalletId).map {
reduxStateHolder.onUserWalletSelected(it)
it
}
return userWalletsListRepository.select(userWalletId)
}
}

View file

@ -21,7 +21,6 @@ import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.details.impl.R
@ -37,7 +36,6 @@ internal class UserWalletSaver @Inject constructor(
private val scanCardProcessor: ScanCardProcessor,
private val saveWalletUseCase: SaveWalletUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val reduxStateHolder: ReduxStateHolder,
private val messageSender: UiMessageSender,
private val router: Router,
) {
@ -94,9 +92,6 @@ internal class UserWalletSaver @Inject constructor(
}
},
transform = {
// call only if wallet is successfully saved
reduxStateHolder.onUserWalletSelected(userWallet)
router.popTo<AppRoute.Wallet>()
},
)

View file

@ -27,7 +27,6 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
@ -69,7 +68,6 @@ internal class HomeModel @Inject constructor(
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletsListRepository: UserWalletsListRepository,
private val reduxStateHolder: ReduxStateHolder,
private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
@ -212,7 +210,6 @@ internal class HomeModel @Inject constructor(
}
},
ifRight = {
reduxStateHolder.onUserWalletSelected(userWallet)
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse, userWallet.isImported)
appRouter.replaceAll(AppRoute.Wallet)