Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-27 14:34:07 +01:00
commit 22fc180d9e
17 changed files with 142 additions and 28 deletions

View file

@ -261,7 +261,10 @@ internal class ChildFactory @Inject constructor(
is AppRoute.Onboarding.Mode.UpgradeHotWallet ->
OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId)
is AppRoute.Onboarding.Mode.AddressSync ->
OnboardingEntryComponent.Mode.AddressSync(mode.userWalletId)
OnboardingEntryComponent.Mode.AddressSync(
mode.userWalletId,
mode.isWalletStarted,
)
},
),
componentFactory = onboardingEntryComponentFactory,

View file

@ -334,7 +334,7 @@ sealed class AppRoute(val path: String) : Route {
data object RecreateWalletTwin : Mode() // reset twins
data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog)
data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet
data class AddressSync(val userWalletId: UserWalletId) : Mode()
data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode()
}
}

View file

@ -239,7 +239,10 @@ internal class CreateWalletStartModel @Inject constructor(
val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) {
AppRoute.Onboarding(
scanResponse = scanResponse,
mode = AppRoute.Onboarding.Mode.AddressSync(userWalletId = userWallet.walletId),
mode = AppRoute.Onboarding.Mode.AddressSync(
userWalletId = userWallet.walletId,
isWalletStarted = false,
),
)
} else {
AppRoute.Wallet

View file

@ -538,6 +538,7 @@ internal class CreateWalletStartModelTest {
scanResponse = testScanResponse,
mode = AppRoute.Onboarding.Mode.AddressSync(
userWalletId = testUserWalletId,
isWalletStarted = false,
),
)
),

View file

@ -20,6 +20,7 @@ dependencies {
implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api)
implementation(projects.features.tangempay.details.api)
implementation(projects.features.onboardingV2.api)
/* Project - Core */
implementation(projects.core.decompose)

View file

@ -24,6 +24,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.details.impl.R
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@ -38,6 +39,7 @@ internal class UserWalletSaver @Inject constructor(
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val messageSender: UiMessageSender,
private val router: Router,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
) {
suspend fun scanAndSaveUserWallet(scope: CoroutineScope) {
@ -55,7 +57,7 @@ internal class UserWalletSaver @Inject constructor(
block = {
scanResponse ?: return
val userWallet = createUserWallet(scanResponse)
saveWallet(userWallet)
saveWallet(userWallet, scanResponse)
},
recover = {
val message = it.message
@ -69,7 +71,7 @@ internal class UserWalletSaver @Inject constructor(
)
}
private suspend fun Raise<Error>.saveWallet(userWallet: UserWallet) {
private suspend fun Raise<Error>.saveWallet(userWallet: UserWallet, scanResponse: ScanResponse) {
fold(
block = {
saveWalletUseCase(
@ -92,7 +94,19 @@ internal class UserWalletSaver @Inject constructor(
}
},
transform = {
router.popTo<AppRoute.Wallet>()
if (onboardingV2FeatureToggles.isAddressSyncEnabled) {
router.push(
AppRoute.Onboarding(
scanResponse = scanResponse,
mode = AppRoute.Onboarding.Mode.AddressSync(
userWalletId = userWallet.walletId,
isWalletStarted = true,
),
),
)
} else {
router.popTo<AppRoute.Wallet>()
}
},
)
}

View file

@ -19,7 +19,7 @@ interface OnboardingEntryComponent : ComposableContentComponent {
data object RecreateWalletTwin : Mode()
data object ContinueFinalize : Mode()
data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode()
data class AddressSync(val userWalletId: UserWalletId) : Mode()
data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode()
}
interface Factory : ComponentFactory<Params, OnboardingEntryComponent>

View file

@ -58,6 +58,7 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.onramp)
implementation(projects.domain.transaction)
implementation(projects.domain.staking)
/** Tangem libraries */
implementation(tangemDeps.hot.core)

View file

@ -2,4 +2,6 @@ package com.tangem.features.onboarding.v2.addresssync
import com.tangem.core.ui.decompose.ComposableContentComponent
interface AddressSyncComponent : ComposableContentComponent
interface AddressSyncComponent : ComposableContentComponent {
data class Params(val isWalletStarted: Boolean)
}

View file

@ -32,6 +32,7 @@ import com.tangem.features.pushnotifications.api.PushNotificationsParams
internal class DefaultAddressSyncComponent(
appComponentContext: AppComponentContext,
params: MultiWalletChildParams,
private val addressSyncParams: AddressSyncComponent.Params,
private val askBiometryComponentFactory: AskBiometryComponent.Factory,
private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory,
) : AppComponentContext by appComponentContext, AddressSyncComponent {
@ -83,7 +84,11 @@ internal class DefaultAddressSyncComponent(
},
)
AddressSyncState.Exit -> LaunchedEffect(Unit) {
router.replaceAll(AppRoute.Wallet)
if (addressSyncParams.isWalletStarted) {
router.popTo(AppRoute.Wallet)
} else {
router.replaceAll(AppRoute.Wallet)
}
}
}
}

View file

@ -8,18 +8,24 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.domain.settings.ShouldShowAskBiometryUseCase
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -34,6 +40,9 @@ internal class AddressSyncModel @Inject constructor(
private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
private val stakingIdFactory: StakingIdFactory,
paramsContainer: ParamsContainer,
) : Model() {
@ -46,12 +55,10 @@ internal class AddressSyncModel @Inject constructor(
)
init {
params.innerNavigation.update { innerNavigationState ->
innerNavigationState.copy(
stackSize = AddressSyncStep.ASK_BIOMETRY.pageNumber,
stackMaxSize = ADDRESS_SYNC_MAX_STEPS,
)
}
params.innerNavigation.value = MultiWalletInnerNavigationState(
stackSize = AddressSyncStep.ASK_BIOMETRY.pageNumber,
stackMaxSize = ADDRESS_SYNC_MAX_STEPS,
)
modelScope.launch {
trySkippingScreen(AddressSyncStep.ASK_BIOMETRY)
}
@ -152,11 +159,41 @@ internal class AddressSyncModel @Inject constructor(
)
TangemLogger.e("Failed to derive public keys", throwable)
},
ifRight = { state.value = AddressSyncState.Exit },
ifRight = {
listOf(
launch { fetchNetworks(cryptoCurrencies) },
launch { fetchStaking(cryptoCurrencies) },
).joinAll()
state.value = AddressSyncState.Exit
},
)
}
}
private suspend fun fetchNetworks(cryptoCurrencies: List<CryptoCurrency>) {
multiNetworkStatusFetcher.invoke(
MultiNetworkStatusFetcher.Params(
userWalletId = walletId,
networks = cryptoCurrencies.map(CryptoCurrency::network).toSet(),
),
)
.onLeft { TangemLogger.e("Unable to fetch networks: $it") }
}
private suspend fun fetchStaking(cryptoCurrencies: List<CryptoCurrency>) {
val stakingIds = cryptoCurrencies.mapNotNullTo(hashSetOf()) {
stakingIdFactory.create(userWalletId = walletId, cryptoCurrency = it).getOrNull()
}
multiStakingBalanceFetcher(
params = MultiStakingBalanceFetcher.Params(
userWalletId = walletId,
stakingIds = stakingIds,
),
)
.onLeft { TangemLogger.e("Unable to fetch yield balances: $it") }
}
private companion object {
const val ADDRESS_SYNC_MAX_STEPS = 3
}

View file

@ -82,7 +82,10 @@ internal class OnboardingEntryModel @Inject constructor(
is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet(
userWalletId = mode.userWalletId,
)
is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync(mode.userWalletId)
is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync(
mode.userWalletId,
mode.isWalletStarted,
)
else -> error("Incorrect onboarding type")
}

View file

@ -24,7 +24,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi
data object AddBackup : Mode()
data object ContinueFinalize : Mode()
data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode()
data class AddressSync(val userWalletId: UserWalletId) : Mode()
data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode()
}
interface Factory : ComponentFactory<Params, OnboardingMultiWalletComponent>

View file

@ -26,6 +26,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigation
import com.tangem.core.decompose.navigation.inner.InnerNavigationState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent
import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
@ -192,12 +193,18 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
onBack = { model.onBack() },
onEvent = ::handleFinalizeComponentEvent,
)
AddressSync -> DefaultAddressSyncComponent(
appComponentContext = childContext,
params = childParams,
askBiometryComponentFactory = askBiometryComponentFactory,
pushNotificationsComponentFactory = pushNotificationsComponentFactory,
)
AddressSync -> {
val mode = params.mode as OnboardingMultiWalletComponent.Mode.AddressSync
DefaultAddressSyncComponent(
appComponentContext = childContext,
params = childParams,
addressSyncParams = AddressSyncComponent.Params(
isWalletStarted = mode.isWalletStarted,
),
askBiometryComponentFactory = askBiometryComponentFactory,
pushNotificationsComponentFactory = pushNotificationsComponentFactory,
)
}
Done -> error("Unexpected Done state")
}
}

View file

@ -8,9 +8,12 @@ import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.domain.settings.ShouldShowAskBiometryUseCase
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.features.onboarding.v2.TitleProvider
@ -42,6 +45,9 @@ internal class AddressSyncModelTest {
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val derivePublicKeysUseCase: DerivePublicKeysUseCase = mockk()
private val paramsContainer: ParamsContainer = mockk()
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher = mockk()
private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk()
private val stakingIdFactory: StakingIdFactory = mockk()
private val testInnerNavigation = MutableStateFlow(
value = MultiWalletInnerNavigationState(
stackSize = 0,
@ -54,7 +60,10 @@ internal class AddressSyncModelTest {
every { innerNavigation } returns testInnerNavigation
every { parentParams } returns mockk {
every { titleProvider } returns this@AddressSyncModelTest.titleProvider
every { mode } returns OnboardingMultiWalletComponent.Mode.AddressSync(walletId)
every { mode } returns OnboardingMultiWalletComponent.Mode.AddressSync(
userWalletId = walletId,
isWalletStarted = false,
)
}
}
@ -236,7 +245,11 @@ internal class AddressSyncModelTest {
@Test
fun `GIVEN success state WHEN Sync THEN state becomes Exit`() = runTest {
val currencies = listOf<CryptoCurrency>(mockk(), mockk(), mockk())
val currencies = listOf<CryptoCurrency>(
mockk { every { network } returns mockk() },
mockk { every { network } returns mockk() },
mockk { every { network } returns mockk() },
)
every { multiAccountListSupplier() } returns flowOf(
listOf(
AccountList.empty(
@ -246,6 +259,11 @@ internal class AddressSyncModelTest {
),
)
coEvery { derivePublicKeysUseCase(walletId, currencies) } returns Either.Right(Unit)
coEvery { multiNetworkStatusFetcher.invoke(any()) } returns Either.Right(Unit)
coEvery {
stakingIdFactory.create(userWalletId = walletId, cryptoCurrency = any())
} returns Either.Right(mockk())
coEvery { multiStakingBalanceFetcher(any()) } returns Either.Right(Unit)
val model = createModel(this)
advanceUntilIdle()
@ -254,6 +272,8 @@ internal class AddressSyncModelTest {
advanceUntilIdle()
coVerify { derivePublicKeysUseCase(walletId, currencies) }
coVerify { multiNetworkStatusFetcher.invoke(any()) }
coVerify { multiStakingBalanceFetcher(any()) }
Assertions.assertEquals(AddressSyncState.Exit, model.state.value)
}
@ -309,6 +329,9 @@ internal class AddressSyncModelTest {
multiWalletAccountListFetcher = multiWalletAccountListFetcher,
multiAccountListSupplier = multiAccountListSupplier,
derivePublicKeysUseCase = derivePublicKeysUseCase,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiStakingBalanceFetcher = multiStakingBalanceFetcher,
stakingIdFactory = stakingIdFactory,
paramsContainer = paramsContainer,
)
}

View file

@ -14,6 +14,7 @@ android {
dependencies {
implementation(projects.features.welcome.api)
implementation(projects.features.wallet.api)
implementation(projects.features.onboardingV2.api)
/** Core */
implementation(projects.core.configToggles)

View file

@ -28,6 +28,7 @@ import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.NonBiometricUnlockWalletUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.features.wallet.utils.UserWalletsFetcher
import com.tangem.features.welcome.impl.ui.state.WelcomeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -54,9 +55,10 @@ internal class WelcomeModel @Inject constructor(
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
userWalletsFetcherFactory: UserWalletsFetcher.Factory,
private val hotWalletRestrictionManager: HotWalletRestrictionManager,
hotWalletRestrictionManager: HotWalletRestrictionManager,
private val scanCardProcessor: ScanCardProcessor,
private val messageSender: UiMessageSender,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
) : Model() {
val uiState: StateFlow<WelcomeUM>
@ -215,7 +217,18 @@ internal class WelcomeModel @Inject constructor(
}
}
.onRight {
router.replaceAll(AppRoute.Wallet)
val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) {
AppRoute.Onboarding(
scanResponse = scanResponse,
mode = AppRoute.Onboarding.Mode.AddressSync(
userWalletId = userWallet.walletId,
isWalletStarted = false,
),
)
} else {
AppRoute.Wallet
}
router.replaceAll(route)
}
},
onCancel = {},