Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-24 12:33:42 +03:00
parent ac01e45371
commit 8db160652c
17 changed files with 421 additions and 1 deletions

View file

@ -14,6 +14,7 @@ android {
dependencies {
/** Api */
implementation(projects.features.hotWallet.api)
implementation(projects.features.onboardingV2.api)
implementation(projects.features.pushNotifications.api)
/** Core modules */

View file

@ -5,21 +5,39 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.hotwallet.UpgradeWalletComponent
import com.tangem.features.hotwallet.upgradewallet.ui.UpgradeWalletContent
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Suppress("UnusedPrivateMember")
internal class DefaultUpgradeWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: UpgradeWalletComponent.Params,
resetCardsComponentFactory: ResetCardsComponent.Factory,
) : UpgradeWalletComponent, AppComponentContext by context {
private val model: UpgradeWalletModel = getOrCreateModel(params)
private val resetCardsComponent = resetCardsComponentFactory.create(
context = child("ResetCardsComponent"),
params = ResetCardsComponent.Params(
callbacks = model.resetCardsComponentCallbacks,
),
)
init {
model.startResetCardsFlow
.onEach { resetCardsComponent.startResetCardsFlow(it) }
.launchIn(componentScope)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()

View file

@ -16,20 +16,25 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.features.hotwallet.UpgradeWalletComponent
import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.extensions.localizedDescriptionRes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@ -50,9 +55,13 @@ internal class UpgradeWalletModel @Inject constructor(
private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase,
private val tangemSdkManager: TangemSdkManager,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
) : Model() {
private val params = paramsContainer.require<UpgradeWalletComponent.Params>()
val resetCardsComponentCallbacks = ResetCardsModelCallbacks()
val startResetCardsFlow = MutableSharedFlow<UserWallet.Cold>()
internal val uiState: StateFlow<UpgradeWalletUM>
field = MutableStateFlow(
UpgradeWalletUM(
@ -89,6 +98,13 @@ internal class UpgradeWalletModel @Inject constructor(
tangemSdkManager
.scanProduct()
.doOnSuccess {
// Check if user attempted to upgrade before but something went wrong and a full reset is required
val userWallet = coldUserWalletBuilderFactory.create(it).build()
if (userWallet?.walletId == params.userWalletId && BackupValidator.isValidFull(it.card).not()) {
startResetCardsFlow.emit(userWallet)
return@doOnSuccess
}
delay(DELAY_SDK_DIALOG_CLOSE)
tangemSdkManager.changeDisplayedCardIdNumbersCount(it)
navigateToUpgradeFlow(it)
@ -143,4 +159,14 @@ internal class UpgradeWalletModel @Inject constructor(
),
)
}
inner class ResetCardsModelCallbacks : ResetCardsComponent.ModelCallbacks {
override fun onCancel() {
setLoading(false)
}
override fun onComplete() {
setLoading(false)
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onboarding.v2.util
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.domain.models.wallet.UserWallet
interface ResetCardsComponent {
fun startResetCardsFlow(createdUserWallet: UserWallet.Cold)
data class Params(
val callbacks: ModelCallbacks,
)
interface ModelCallbacks {
fun onCancel()
fun onComplete()
}
interface Factory : ComponentFactory<Params, ResetCardsComponent>
}

View file

@ -95,6 +95,7 @@ internal class OnboardingEntryModel @Inject constructor(
withSeedPhraseFlow = scanResponse.productType != ProductType.Wallet,
titleProvider = titleProvider,
onDone = ::onMultiWalletOnboardingDone,
onForceExit = ::popOrExitComponentScreen,
mode = multiWalletNavigationMode,
)
}
@ -204,6 +205,14 @@ internal class OnboardingEntryModel @Inject constructor(
}
}
private fun popOrExitComponentScreen() {
router.pop(onComplete = { success ->
if (!success) {
exitComponentScreen()
}
})
}
private fun exitComponentScreen() {
// new flow
if (hotWalletFeatureToggles.isHotWalletEnabled) {

View file

@ -36,6 +36,7 @@ internal class OnboardingChildFactory @Inject constructor(
withSeedPhraseFlow = route.withSeedPhraseFlow,
titleProvider = route.titleProvider,
onDone = route.onDone,
onForceExit = route.onForceExit,
mode = route.mode,
),
)

View file

@ -25,6 +25,7 @@ sealed class OnboardingRoute : Route {
val withSeedPhraseFlow: Boolean,
val mode: OnboardingMultiWalletComponent.Mode,
val onDone: (UserWallet.Cold) -> Unit,
val onForceExit: () -> Unit,
) : OnboardingRoute()
data class Visa(

View file

@ -16,6 +16,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi
val withSeedPhraseFlow: Boolean,
val mode: Mode,
val onDone: (UserWallet.Cold) -> Unit,
val onForceExit: () -> Unit,
)
sealed class Mode {

View file

@ -41,6 +41,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiW
import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.Step.*
import com.tangem.features.onboarding.v2.multiwallet.impl.ui.OnboardingMultiWallet
import com.tangem.features.onboarding.v2.multiwallet.impl.ui.WalletArtworksState
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -51,6 +52,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
@Assisted private val context: AppComponentContext,
@Assisted private val params: OnboardingMultiWalletComponent.Params,
private val analyticsHandler: AnalyticsEventHandler,
private val resetCardsComponentFactory: ResetCardsComponent.Factory,
) : OnboardingMultiWalletComponent, AppComponentContext by context {
private val model: OnboardingMultiWalletModel = getOrCreateModel(params)
@ -178,6 +180,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
)
Finalize -> MultiWalletFinalizeComponent(
context = childContext,
resetCardsComponentFactory = resetCardsComponentFactory,
params = childParams,
backButtonClickFlow = backButtonClickFlow,
onBack = { model.onBack() },
@ -249,6 +252,9 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
MultiWalletFinalizeComponent.Event.ThreeBackupCardsAdded -> {
handleNavigationEvent(Done)
}
MultiWalletFinalizeComponent.Event.ExitFromFlow -> {
params.onForceExit()
}
}
}

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onboarding.v2.impl.R
@ -13,13 +14,17 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model.MultiWalletFinalizeModel
import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.MultiWalletFinalize
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Suppress("UnusedPrivateMember")
internal class MultiWalletFinalizeComponent(
context: AppComponentContext,
resetCardsComponentFactory: ResetCardsComponent.Factory,
params: MultiWalletChildParams,
backButtonClickFlow: SharedFlow<Unit>,
onBack: () -> Unit,
@ -28,6 +33,13 @@ internal class MultiWalletFinalizeComponent(
private val model: MultiWalletFinalizeModel = getOrCreateModel(params)
private val resetCardsComponent = resetCardsComponentFactory.create(
context = child("ResetCardsComponent"),
params = ResetCardsComponent.Params(
callbacks = model.resetCardsModelCallbacks,
),
)
init {
params.innerNavigation.update {
it.copy(
@ -51,6 +63,10 @@ internal class MultiWalletFinalizeComponent(
componentScope.launch {
model.onBackFlow.collect { onBack() }
}
model.startFullResetFlow
.onEach { resetCardsComponent.startResetCardsFlow(it) }
.launchIn(componentScope)
}
@Composable
@ -66,6 +82,6 @@ internal class MultiWalletFinalizeComponent(
}
enum class Event {
OneBackupCardAdded, TwoBackupCardsAdded, ThreeBackupCardsAdded
OneBackupCardAdded, TwoBackupCardsAdded, ThreeBackupCardsAdded, ExitFromFlow
}
}

View file

@ -31,6 +31,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.MultiWa
import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state.MultiWalletFinalizeUM
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog
import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage.*
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.sdk.api.TangemSdkManager
@ -62,6 +63,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private val onboardingRepository: OnboardingRepository,
private val walletsRepository: WalletsRepository,
private val uiMessageSender: UiMessageSender,
private val backupValidator: BackupValidator,
) : Model() {
private val params = paramsContainer.require<MultiWalletChildParams>()
@ -76,6 +78,8 @@ internal class MultiWalletFinalizeModel @Inject constructor(
val uiState = _uiState.asStateFlow()
val onBackFlow = MutableSharedFlow<Unit>()
val onEvent = MutableSharedFlow<MultiWalletFinalizeComponent.Event>()
val resetCardsModelCallbacks = ResetCardsModelCallbacks()
val startFullResetFlow = MutableSharedFlow<UserWallet.Cold>()
init {
modelScope.launch {
@ -231,9 +235,21 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private fun finishBackup() {
modelScope.launch {
setLoading(true)
val scanResponse = params.multiWalletState.value.currentScanResponse
val userWalletCreated = createUserWallet(scanResponse)
// Validate wallet before saving
// If something went wrong - start full reset flow
if (walletHasBackupError || !backupValidator.isValidFull(scanResponse.card)) {
startFullResetFlow.emit(
userWalletCreated.copy(
scanResponse = scanResponse.updateScanResponseAfterBackup(),
),
)
return@launch
}
val userWallet = when (params.parentParams.mode) {
OnboardingMultiWalletComponent.Mode.Onboarding,
OnboardingMultiWalletComponent.Mode.ContinueFinalize,
@ -359,4 +375,25 @@ internal class MultiWalletFinalizeModel @Inject constructor(
val mask = "$space*$space*$space*$space"
return mask + last4
}
private fun setLoading(isLoading: Boolean) {
_uiState.update { it.copy(isButtonLoading = isLoading) }
}
inner class ResetCardsModelCallbacks : ResetCardsComponent.ModelCallbacks {
override fun onCancel() {
modelScope.launch {
onEvent.emit(MultiWalletFinalizeComponent.Event.ExitFromFlow)
}
}
override fun onComplete() {
modelScope.launch {
onboardingRepository.clearUnfinishedFinalizeOnboarding()
backupServiceHolder.backupService.get()?.discardSavedBackup()
onEvent.emit(MultiWalletFinalizeComponent.Event.ExitFromFlow)
}
}
}
}

View file

@ -72,6 +72,7 @@ internal fun MultiWalletFinalize(state: MultiWalletFinalizeUM, modifier: Modifie
text = stringResourceSafe(
if (state.isRing) R.string.onboarding_button_backup_ring else R.string.onboarding_button_backup_origin,
),
showProgress = state.isButtonLoading,
iconResId = R.drawable.ic_tangem_24,
onClick = state.onScanClick,
)

View file

@ -4,6 +4,7 @@ import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
internal data class MultiWalletFinalizeUM(
val step: Step = Step.Primary,
val isButtonLoading: Boolean = false,
val isRing: Boolean = false,
val scanPrimary: Boolean = true,
val cardNumber: String = "",

View file

@ -0,0 +1,30 @@
package com.tangem.features.onboarding.v2.util.impl
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import com.tangem.features.onboarding.v2.util.impl.model.ResetCardsModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultResetCardsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: ResetCardsComponent.Params,
) : ResetCardsComponent, AppComponentContext by context {
private val model: ResetCardsModel = getOrCreateModel(params)
override fun startResetCardsFlow(createdUserWallet: UserWallet.Cold) {
model.startResetCardsFlow(createdUserWallet)
}
@AssistedFactory
interface Factory : ResetCardsComponent.Factory {
override fun create(
context: AppComponentContext,
params: ResetCardsComponent.Params,
): DefaultResetCardsComponent
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.onboarding.v2.util.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import com.tangem.features.onboarding.v2.util.impl.DefaultResetCardsComponent
import com.tangem.features.onboarding.v2.util.impl.model.ResetCardsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindResetCardsComponent(factory: DefaultResetCardsComponent.Factory): ResetCardsComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(ResetCardsModel::class)
fun provideModel(model: ResetCardsModel): Model
}

View file

@ -0,0 +1,174 @@
package com.tangem.features.onboarding.v2.util.impl.model
import com.tangem.common.CompletionResult
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.card.common.util.getBackupCardsCount
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import kotlin.coroutines.resume
@ModelScoped
internal class ResetCardsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val tangemSdkManager: TangemSdkManager,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<ResetCardsComponent.Params>()
private val callbacks = params.callbacks
private val jobHolder = JobHolder()
fun startResetCardsFlow(wallet: UserWallet.Cold) {
modelScope.launch {
startFullResetFlow(wallet)
}.saveIn(jobHolder)
}
private suspend fun startFullResetFlow(createdUserWallet: UserWallet.Cold) {
suspendCancellableCoroutine { continuation ->
uiMessageSender.send(
DialogMessage.invoke(
title = resourceReference(R.string.reset_cards_dialog_first_title),
isDismissable = false,
message = resourceReference(R.string.reset_cards_dialog_first_description),
firstActionBuilder = {
cancelAction {
callbacks.onCancel()
onDismissRequest()
continuation.resume(Unit)
}
},
secondActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.card_settings_action_sheet_reset),
isWarning = true,
onClick = {
modelScope.launch {
repeatUntilTrue { resetPrimaryCard(createdUserWallet) }
continuation.resume(Unit)
onDismissRequest()
startResetBackupCardsFlow(createdUserWallet)
}
},
)
},
onDismissRequest = {},
),
)
}
}
private suspend fun startResetBackupCardsFlow(createdUserWallet: UserWallet.Cold) {
val backupCardsCount = createdUserWallet.scanResponse.getBackupCardsCount() ?: 0
if (backupCardsCount == 0) {
completeResetFlow()
return
}
repeat(backupCardsCount) { index ->
suspendCancellableCoroutine { continuation ->
uiMessageSender.send(
DialogMessage.invoke(
title = resourceReference(R.string.card_settings_continue_reset_alert_title),
isDismissable = false,
message = resourceReference(R.string.reset_cards_dialog_next_device_description),
firstActionBuilder = {
cancelAction {
callbacks.onCancel()
onDismissRequest()
continuation.resume(Unit)
}
},
secondActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.card_settings_action_sheet_reset),
isWarning = true,
onClick = {
modelScope.launch {
repeatUntilTrue { resetBackupCard(index + 1, createdUserWallet.walletId) }
onDismissRequest()
if (index == backupCardsCount - 1) {
completeResetFlow()
}
continuation.resume(Unit)
}
},
)
},
onDismissRequest = {},
),
)
}
}
}
private suspend fun completeResetFlow() {
suspendCancellableCoroutine { continuation ->
uiMessageSender.send(
DialogMessage.invoke(
title = resourceReference(R.string.card_settings_completed_reset_alert_title),
isDismissable = false,
message = resourceReference(R.string.reset_cards_dialog_complete_description),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_done),
onClick = {
modelScope.launch {
onDismissRequest()
callbacks.onComplete()
continuation.resume(Unit)
}
},
)
},
onDismissRequest = {},
),
)
}
}
private suspend fun resetPrimaryCard(createdUserWallet: UserWallet.Cold): Boolean {
val scanResponse = createdUserWallet.scanResponse
val result = tangemSdkManager.resetToFactorySettings(
cardId = scanResponse.card.cardId,
allowsRequestAccessCodeFromRepository = true,
)
return when (result) {
is CompletionResult.Failure -> false
is CompletionResult.Success -> true
}
}
private suspend fun resetBackupCard(cardIndex: Int, userWalletId: UserWalletId): Boolean {
val result = tangemSdkManager.resetBackupCard(cardIndex, userWalletId)
return when (result) {
is CompletionResult.Failure -> false
is CompletionResult.Success -> true
}
}
private inline fun repeatUntilTrue(action: () -> Boolean) {
while (true) {
if (action()) break
}
}
}