From c846d8c2767a725d8035feec1afc8bcbeb689441 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 09:28:48 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 9 + .../com/tangem/common/routing/AppRoute.kt | 3 + .../configs/feature_toggles_config.json | 5 +- .../create-wallet-start/impl/build.gradle.kts | 12 + .../CreateWalletStartModel.kt | 11 +- .../CreateWalletStartModelTest.kt | 651 ++++++++++++++++++ .../v2/OnboardingV2FeatureToggles.kt | 1 + .../v2/DefaultOnboardingV2FeatureToggles.kt | 2 + .../v2/addresssync/AddressSyncComponent.kt | 13 + .../DefaultAddressSyncComponent.kt | 39 ++ .../di/AddressSyncComponentModule.kt | 18 + .../addresssync/di/AddressSyncModelModule.kt | 20 + .../v2/addresssync/entity/AddressSyncUM.kt | 5 + .../v2/addresssync/model/AddressSyncModel.kt | 22 + .../v2/addresssync/ui/AddressSyncContent.kt | 54 ++ 15 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 588ae2ef38..72e8f32bb6 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -25,6 +25,7 @@ import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.nft.component.NFTComponent +import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent import com.tangem.features.onramp.component.* import com.tangem.features.pushnotifications.api.PushNotificationsComponent @@ -111,6 +112,7 @@ internal class ChildFactory @Inject constructor( private val kycComponentFactory: KycComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory, + private val addressSyncComponentFactory: AddressSyncComponent.Factory, ) { @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -692,6 +694,13 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } + is AppRoute.AddressSync -> { + createComponentChild( + context = context, + params = AddressSyncComponent.Params(data = Unit), + componentFactory = addressSyncComponentFactory, + ) + } } } } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index e78861b261..fd3d57f697 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -484,4 +484,7 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") + + @Serializable + data object AddressSync : AppRoute(path = "/address_sync") } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index d4422d4bf3..d4053a9ee6 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -7,7 +7,6 @@ "name": "VISA_ONBOARDING_ENABLED", "version": "undefined" }, - { "name": "STAKING_ETH_ENABLED", "version": "undefined" @@ -67,5 +66,9 @@ { "name": "WALLET_CONNECT_BITCOIN_ENABLED", "version": "undefined" + }, + { + "name": "ADDRESS_SYNC_ENABLED", + "version": "undefined" } ] diff --git a/features/create-wallet-start/impl/build.gradle.kts b/features/create-wallet-start/impl/build.gradle.kts index c280d4160c..f62c9a51f6 100644 --- a/features/create-wallet-start/impl/build.gradle.kts +++ b/features/create-wallet-start/impl/build.gradle.kts @@ -11,10 +11,15 @@ android { namespace = "com.tangem.features.createwalletstart.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.createWalletStart.api) implementation(projects.features.hotWallet.api) + implementation(projects.features.onboardingV2.api) /** Project - Domain */ implementation(projects.domain.card) @@ -71,4 +76,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 5f590c887c..4344425538 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -33,14 +33,15 @@ import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val HIDE_PROGRESS_DELAY = 400L @@ -65,6 +66,7 @@ internal class CreateWalletStartModel @Inject constructor( private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, private val appsFlyerStore: AppsFlyerStore, + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -234,7 +236,12 @@ internal class CreateWalletStartModel @Inject constructor( }, ifRight = { setLoading(false) - appRouter.replaceAll(AppRoute.Wallet) + val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { + AppRoute.AddressSync + } else { + AppRoute.Wallet + } + appRouter.replaceAll(route) }, ) } diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt new file mode 100644 index 0000000000..2d780979b5 --- /dev/null +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -0,0 +1,651 @@ +package com.tangem.features.createwalletstart + +import arrow.core.left +import arrow.core.right +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent +import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.analytics.IntroductionProcess +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.hotwallet.IsHotWalletCreationSupported +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class CreateWalletStartModelTest { + + private val router: Router = mockk(relaxed = true) + private val appRouter: AppRouter = mockk(relaxed = true) + private val scanCardProcessor: ScanCardProcessor = mockk() + private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true) + private val settingsRepository: SettingsRepository = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk() + private val coldUserWalletBuilder: ColdUserWalletBuilder = mockk() + private val saveWalletUseCase: SaveWalletUseCase = mockk() + private val isHotWalletCreationSupported: IsHotWalletCreationSupported = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase = mockk() + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val appsFlyerStore: AppsFlyerStore = mockk() + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles = mockk() + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val testScanResponse: ScanResponse = mockk(relaxed = true) + private val testColdWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns testUserWalletId + } + + @BeforeEach + fun setUp() { + coEvery { appsFlyerStore.get() } returns null + coEvery { settingsRepository.shouldSaveAccessCodes() } returns false + every { coldUserWalletBuilderFactory.create(any()) } returns coldUserWalletBuilder + every { coldUserWalletBuilder.build() } returns testColdWallet + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } just Runs + } + + @Test + fun `GIVEN ColdWallet mode WHEN onScanClick THEN ButtonScanCard event sent AND scan called`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { + it.source == AnalyticsParam.ScreensSources.CreateWalletIntro + }, + ) + } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN HotWallet mode WHEN onScanClick THEN ButtonScanCard event sent AND scan called`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { + it.source == AnalyticsParam.ScreensSources.CreateWalletIntro + }, + ) + } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN ColdWallet AND hot wallet not supported WHEN otherMethodClick THEN dialog sent`() = runTest { + every { isHotWalletCreationSupported() } returns false + every { isHotWalletCreationSupported.getLeastVersionName() } returns "13" + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.otherMethodClick.invoke() + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send( + match { true }, + ) + } + verify { uiMessageSender.send(match { true }) } + verify(exactly = 0) { router.push(any(), any()) } + } + + @Test + fun `GIVEN HotWallet AND hot wallet supported WHEN onPrimaryButtonClick THEN CreateMobileWallet pushed`() = + runTest { + every { isHotWalletCreationSupported() } returns true + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.onPrimaryButtonClick.invoke() + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send( + match { true }, + ) + } + verify { + router.push( + route = AppRoute.CreateMobileWallet( + source = AnalyticsParam.ScreensSources.CreateWalletIntro.value, + ), + onComplete = any(), + ) + } + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN ColdWallet mode WHEN onPrimaryButtonClick THEN buy link opened`() = runTest { + val testUrl = "https://buy.tangem.com" + coEvery { + generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) + } returns testUrl + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onPrimaryButtonClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + coVerify { generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) } + verify { urlOpener.openUrl(testUrl) } + } + + @Test + fun `GIVEN HotWallet mode WHEN onBuyClick THEN buy link opened`() = runTest { + val testUrl = "https://buy.tangem.com" + coEvery { + generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) + } returns testUrl + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.otherMethodClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + coVerify { generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) } + verify { urlOpener.openUrl(testUrl) } + } + + @Test + fun `WHEN scanCard THEN access code policy set AND scanProcessor called`() = runTest { + coEvery { settingsRepository.shouldSaveAccessCodes() } returns true + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN NfcFeatureIsUnavailable WHEN handleScanError THEN nfcFeatureUnavailable dialog sent`() = runTest { + val error = TangemSdkError.NfcFeatureIsUnavailable() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(7) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { uiMessageSender.send(match { true }) } + } + + @Test + fun `GIVEN generic TangemSdkError WHEN handleScanError THEN no dialog sent`() = runTest { + val error: TangemSdkError = mockk(relaxed = true) + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(7) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN non-sdk TangemError WHEN handleScanError THEN no dialog sent`() = runTest { + val error: TangemError = mockk(relaxed = true) + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(7) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN builder returns null WHEN proceedWithScanResponse THEN saveWalletUseCase not called`() = runTest { + every { coldUserWalletBuilder.build() } returns null + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { coldUserWalletBuilderFactory.create(scanResponse = testScanResponse) } + coVerify(exactly = 0) { saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) } + verify(exactly = 0) { appRouter.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN save returns WalletAlreadySaved WHEN proceedWithScanResponse THEN unlock called AND Wallet replaced`() = + runTest { + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns SaveWalletError.WalletAlreadySaved(messageId = 0).left() + coEvery { + userWalletsListRepository.unlock( + userWalletId = testUserWalletId, + unlockMethod = any(), + ) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + coVerify { + userWalletsListRepository.unlock( + userWalletId = testUserWalletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(testScanResponse), + ) + } + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + } + + @Test + fun `GIVEN save returns DataError WHEN proceedWithScanResponse THEN no unlock AND no replace`() = runTest { + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns SaveWalletError.DataError(messageId = 0).left() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + coVerify(exactly = 0) { userWalletsListRepository.unlock(any(), any()) } + verify(exactly = 0) { appRouter.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN save success AND isAddressSyncEnabled disabled WHEN proceedWithScanResponse THEN Wallet replaced`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + } + + @Test + fun `GIVEN save success AND isAddressSyncEnabled WHEN proceedWithScanResponse THEN AddressSync replaced`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns true + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.AddressSync), onComplete = any()) } + } + + @Test + fun `WHEN model initialized THEN CreateWalletIntroScreenOpened event sent with referral id`() = runTest { + val refcode = "referralCode" + coEvery { appsFlyerStore.get() } returns AppsFlyerConversionData(refcode = refcode, campaign = null) + + createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + } + + @Test + fun `WHEN ColdWallet WHEN get model THEN correct resources`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + val state = model.uiState.value + assert(state.title == resourceReference(R.string.common_tangem_wallet)) + assert(state.description == resourceReference(R.string.welcome_create_wallet_hardware_description)) + assert( + state.featureItems == persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ) + ) + ) + assert(state.imageResId == R.drawable.img_hardware_wallet) + assert(state.shouldShowScanSecondaryButton) + assert(state.primaryButtonText == resourceReference(R.string.details_buy_wallet),) + assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_mobile_title),) + assert(state.otherMethodDescription == null,) + assert(state.isScanInProgress.not()) + } + + @Test + fun `WHEN HotWallet WHEN get model THEN correct resources`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + val state = model.uiState.value + assert(state.title == resourceReference(R.string.hw_mobile_wallet)) + assert(state.description == resourceReference(R.string.welcome_create_wallet_mobile_description_full)) + assert( + state.featureItems == persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_seamless), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_one_tap), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_stack_fill_new_16, + text = resourceReference(R.string.welcome_create_wallet_feature_assets), + ), + ) + ) + assert(state.imageResId == R.drawable.img_mobile_wallet) + assert(state.shouldShowScanSecondaryButton.not()) + assert(state.primaryButtonText == resourceReference(R.string.welcome_create_wallet_mobile_title)) + assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_use_hardware_title)) + assert(state.otherMethodDescription == resourceReference(R.string.welcome_create_wallet_use_hardware_description)) + assert(state.isScanInProgress.not()) + } + + private fun createModel( + testScope: TestScope, + mode: CreateWalletStartComponent.Mode, + paramsContainer: ParamsContainer = MutableParamsContainer( + value = CreateWalletStartComponent.Params(mode = mode) + ), + ): CreateWalletStartModel { + return CreateWalletStartModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + scanCardProcessor = scanCardProcessor, + cardSdkConfigRepository = cardSdkConfigRepository, + settingsRepository = settingsRepository, + appRouter = appRouter, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + saveWalletUseCase = saveWalletUseCase, + isHotWalletCreationSupported = isHotWalletCreationSupported, + userWalletsListRepository = userWalletsListRepository, + uiMessageSender = uiMessageSender, + generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase, + urlOpener = urlOpener, + trackingContextProxy = trackingContextProxy, + analyticsEventHandler = analyticsEventHandler, + appsFlyerStore = appsFlyerStore, + onboardingV2FeatureToggles = onboardingV2FeatureToggles, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt index 8a3ef86745..b714716c54 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.onboarding.v2 interface OnboardingV2FeatureToggles { val isVisaOnboardingEnabled: Boolean + val isAddressSyncEnabled: Boolean } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt index 6e165ef059..24c5fa9bbb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt @@ -9,4 +9,6 @@ internal class DefaultOnboardingV2FeatureToggles @Inject constructor( ) : OnboardingV2FeatureToggles { override val isVisaOnboardingEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.VISA_ONBOARDING_ENABLED) + override val isAddressSyncEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.ADDRESS_SYNC_ENABLED) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt new file mode 100644 index 0000000000..d097548938 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt @@ -0,0 +1,13 @@ +package com.tangem.features.onboarding.v2.addresssync + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface AddressSyncComponent : ComposableContentComponent { + + data class Params( + val data: Any, // TODO("Will be implemented during [REDACTED_TASK_KEY]") + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt new file mode 100644 index 0000000000..78d1c659fe --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.onboarding.v2.addresssync + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +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.model.getOrCreateModel +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddressSyncComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: AddressSyncComponent.Params, +) : AppComponentContext by appComponentContext, AddressSyncComponent { + + private val model: AddressSyncModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + AddressSyncContent(modifier = modifier, state = state) + + BackHandler(onBack = state.onBackClick) + } + + @AssistedFactory + interface Factory : AddressSyncComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddressSyncComponent.Params, + ): DefaultAddressSyncComponent + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt new file mode 100644 index 0000000000..a9f22b031e --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.onboarding.v2.addresssync.di + +import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent +import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface AddressSyncComponentModule { + + @Binds + @Singleton + fun bindAddressSyncComponentFactory(factory: DefaultAddressSyncComponent.Factory): AddressSyncComponent.Factory +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt new file mode 100644 index 0000000000..95549a70e6 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onboarding.v2.addresssync.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddressSyncModelModule { + + @Binds + @IntoMap + @ClassKey(AddressSyncModel::class) + fun bindAddressSyncModel(model: AddressSyncModel): Model +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt new file mode 100644 index 0000000000..bc58a40b89 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt @@ -0,0 +1,5 @@ +package com.tangem.features.onboarding.v2.addresssync.entity + +internal data class AddressSyncUM( + val onBackClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt new file mode 100644 index 0000000000..b214cef29e --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -0,0 +1,22 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class AddressSyncModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + val uiState: StateFlow + field: MutableStateFlow = MutableStateFlow(getInitialState()) + + private fun getInitialState(): AddressSyncUM { + TODO("Will be implemented during [REDACTED_TASK_KEY]") + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt new file mode 100644 index 0000000000..40b0b158eb --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt @@ -0,0 +1,54 @@ +package com.tangem.features.onboarding.v2.addresssync.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM + +@Composable +internal fun AddressSyncContent(state: AddressSyncUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(color = TangemTheme.colors.background.secondary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithBackButton( + onBackClick = state.onBackClick, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = TangemTheme.dimens.spacing16) + .weight(1f), + ) {} + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddressSyncContentPreview(@PreviewParameter(PreviewStateProvider::class) state: AddressSyncUM) { + TangemThemePreview { + AddressSyncContent(state = state) + } +} + +private class PreviewStateProvider : CollectionPreviewParameterProvider( + buildList { + TODO("Will be implemented during [REDACTED_TASK_KEY]") + }, +) \ No newline at end of file