Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-05 19:19:24 +03:00
commit 24ef1d4ed9
120 changed files with 6250 additions and 675 deletions

View file

@ -17,6 +17,7 @@ dependencies {
implementation(projects.domain.wallets.models)
/* Project - Core */
api(projects.core.analytics.models)
implementation(projects.core.decompose)
implementation(projects.core.ui)

View file

@ -1,11 +1,12 @@
package com.tangem.features.hotwallet
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface CreateMobileWalletComponent : ComposableContentComponent {
data class Params(
val source: String,
val source: AnalyticsParam.ScreensSources,
)
interface Factory : ComponentFactory<Params, CreateMobileWalletComponent>

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.hotwallet.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.hotWallet.api)
@ -78,4 +82,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)
}

View file

@ -129,13 +129,13 @@ internal class AddExistingWalletImportModel @Inject constructor(
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.Finished(
source = AnalyticsParam.ScreensSources.ImportWallet.value,
source = AnalyticsParam.ScreensSources.ImportWallet,
),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
source = AnalyticsParam.ScreensSources.ImportWallet.value,
creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.SeedImport,
source = AnalyticsParam.ScreensSources.ImportWallet,
creationType = AnalyticsParam.WalletCreationType.SeedImport,
seedPhraseLength = mnemonic.mnemonicComponents.size,
passPhraseState = if (passphrase.isNullOrBlank()) {
AnalyticsParam.EmptyFull.Empty

View file

@ -62,7 +62,9 @@ internal class CreateMobileWalletModel @Inject constructor(
init {
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(event = OnboardingAnalyticsEvent.Onboarding.Started(source = params.source))
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.Started(source = params.source),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.SeedPhrase.CreateMobileScreenOpened(source = params.source),
)
@ -95,11 +97,13 @@ internal class CreateMobileWalletModel @Inject constructor(
saveUserWalletUseCase(userWallet)
analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished(source = params.source))
analyticsEventHandler.send(
OnboardingAnalyticsEvent.Onboarding.Finished(source = params.source),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
source = params.source,
creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.NewSeed,
creationType = AnalyticsParam.WalletCreationType.NewSeed,
seedPhraseLength = SEED_PHRASE_LENGTH,
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,

View file

@ -11,7 +11,7 @@ internal data class WalletBackupUM(
val onRecoveryPhraseClick: () -> Unit,
val onGoogleDriveClick: () -> Unit,
val onHardwareWalletClick: () -> Unit,
val backedUp: Boolean,
val isBackedUp: Boolean,
)
internal sealed class BackupStatus {

View file

@ -7,10 +7,13 @@ 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.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
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.models.wallet.UserWallet
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -19,9 +22,9 @@ import com.tangem.features.hotwallet.WalletBackupComponent
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@ -35,6 +38,7 @@ internal class WalletBackupModel @Inject constructor(
private val router: Router,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params: WalletBackupComponent.Params = paramsContainer.require()
@ -59,9 +63,9 @@ internal class WalletBackupModel @Inject constructor(
),
googleDriveStatus = BackupStatus.ComingSoon,
onRecoveryPhraseClick = ::onRecoveryPhraseClick,
onGoogleDriveClick = { },
onGoogleDriveClick = ::onGoogleDriveBackupClick,
onHardwareWalletClick = ::onHardwareWalletClick,
backedUp = false,
isBackedUp = false,
),
)
@ -116,15 +120,16 @@ internal class WalletBackupModel @Inject constructor(
)
},
googleDriveOption = LabelUM(
text = resourceReference(R.string.common_coming_soon),
style = LabelStyle.REGULAR,
text = resourceReference(R.string.hw_backup_no_backup),
style = LabelStyle.WARNING,
),
backedUp = userWallet.backedUp,
isBackedUp = userWallet.backedUp,
googleDriveStatus = BackupStatus.NoBackup,
)
private fun onRecoveryPhraseClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonRecoveryPhrase())
if (uiState.value.backedUp) {
if (uiState.value.isBackedUp) {
getUserWalletUseCase.invoke(params.userWalletId)
.fold(
ifLeft = {
@ -150,6 +155,20 @@ internal class WalletBackupModel @Inject constructor(
}
}
private fun onGoogleDriveBackupClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonGoogleDriveBackup())
uiMessageSender.send(
DialogMessage(
title = resourceReference(id = R.string.hw_backup_google_drive_dialog_title),
message = resourceReference(id = R.string.hw_backup_google_drive_dialog_message),
firstAction = EventMessageAction(
title = resourceReference(id = R.string.common_ok),
onClick = {},
),
),
)
}
private fun showSeedPhrase(hotWallet: UserWallet.Hot) {
modelScope.launch {
unlockHotWalletContextualUseCase.invoke(hotWallet.hotWalletId)

View file

@ -132,7 +132,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
WalletBackupUM(
hardwareWalletOption = LabelUM(
@ -152,7 +152,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
WalletBackupUM(
hardwareWalletOption = LabelUM(
@ -172,7 +172,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
WalletBackupUM(
hardwareWalletOption = null,
@ -189,7 +189,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
),
)

View file

@ -0,0 +1,278 @@
package com.tangem.features.hotwallet.walletbackup.model
import arrow.core.left
import arrow.core.right
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.utils.TrackingContextProxy
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.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase
import com.tangem.features.hotwallet.WalletBackupComponent
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.hot.sdk.model.UnlockHotWallet
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
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.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class WalletBackupModelTest {
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase = mockk()
private val router: Router = mockk(relaxUnitFun = true)
private val trackingContextProxy: TrackingContextProxy = mockk(relaxUnitFun = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true)
private val paramsContainer: ParamsContainer = mockk()
private val walletId = UserWalletId("011")
private val hotWalletId: HotWalletId = mockk()
private val params = WalletBackupComponent.Params(
userWalletId = walletId,
isColdWalletOptionShown = true,
)
private val hotWalletNotBackedUp: UserWallet.Hot = mockk {
every { walletId } returns this@WalletBackupModelTest.walletId
every { hotWalletId } returns this@WalletBackupModelTest.hotWalletId
every { backedUp } returns false
}
private val hotWalletBackedUp: UserWallet.Hot = mockk {
every { walletId } returns this@WalletBackupModelTest.walletId
every { hotWalletId } returns this@WalletBackupModelTest.hotWalletId
every { backedUp } returns true
}
private val coldWallet: UserWallet.Cold = mockk {
every { walletId } returns this@WalletBackupModelTest.walletId
}
@BeforeEach
fun setUp() {
every { paramsContainer.require<WalletBackupComponent.Params>() } returns params
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right())
}
@Test
fun `GIVEN hot wallet WHEN model is created THEN context added AND BackupScreenOpened sent AND state updated`() =
runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right())
val model = createModel(this)
advanceUntilIdle()
verify { trackingContextProxy.addHotWalletContext() }
verify {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.BackupScreenOpened(isBackedUp = false))
}
val state = model.uiState.value
Assertions.assertEquals(false, state.isBackedUp)
Assertions.assertEquals(BackupStatus.NoBackup, state.googleDriveStatus)
}
@Test
fun `GIVEN cold wallet WHEN model is created THEN context added AND BackupScreenOpened not sent AND state untouched`() =
runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(coldWallet.right())
val model = createModel(this)
advanceUntilIdle()
verify { trackingContextProxy.addHotWalletContext() }
verify(exactly = 0) {
analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.BackupScreenOpened> { true })
}
val state = model.uiState.value
Assertions.assertEquals(false, state.isBackedUp)
Assertions.assertEquals(BackupStatus.ComingSoon, state.googleDriveStatus)
}
@Test
fun `GIVEN error WHEN model is created THEN context added AND BackupScreenOpened not sent`() = runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(GetUserWalletError.UserWalletNotFound.left())
createModel(this)
advanceUntilIdle()
verify { trackingContextProxy.addHotWalletContext() }
verify(exactly = 0) {
analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.BackupScreenOpened> { true })
}
}
@Test
fun `WHEN onDestroy THEN trackingContextProxy removeContext is called`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.onDestroy()
verify { trackingContextProxy.removeContext() }
}
@Test
fun `GIVEN backed up hot wallet AND unlock success WHEN onRecoveryPhraseClick THEN ViewPhrase pushed`() = runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right())
every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right()
coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns mockk<UnlockHotWallet>().right()
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) }
verify { router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any()) }
}
@Test
fun `GIVEN backed up hot wallet AND unlock failure WHEN onRecoveryPhraseClick THEN ViewPhrase not pushed`() =
runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right())
every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right()
coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns Throwable("error").left()
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) }
verify(exactly = 0) {
router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any())
}
}
@Test
fun `GIVEN backed up cold wallet WHEN onRecoveryPhraseClick THEN no navigation AND no unlock`() = runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right())
every { getUserWalletUseCase.invoke(walletId) } returns coldWallet.right()
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) }
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
}
@Test
fun `GIVEN not backed up wallet WHEN onRecoveryPhraseClick THEN WalletActivation pushed`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
verify(exactly = 0) { getUserWalletUseCase.invoke(walletId) }
coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) }
verify {
router.push(
route = AppRoute.WalletActivation(userWalletId = walletId, isBackupExists = false),
onComplete = any(),
)
}
}
@Test
fun `WHEN onHardwareWalletClick THEN ButtonHardwareUpdate sent AND WalletHardwareBackup pushed`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onHardwareWalletClick()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonHardwareUpdate> { true }) }
verify {
router.push(
route = AppRoute.WalletHardwareBackup(userWalletId = walletId),
onComplete = any(),
)
}
}
@Test
fun `WHEN onGoogleDriveClick THEN DialogMessage sent AND analytics sent`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onGoogleDriveClick()
verify {
analyticsEventHandler.send(
event = match<WalletSettingsAnalyticEvents.ButtonGoogleDriveBackup> { true }
)
}
verify {
uiMessageSender.send(
match<DialogMessage> {
val isTitleCorrect = it.title == resourceReference(
id = R.string.hw_backup_google_drive_dialog_title
)
val isMessageCorrect = it.message == resourceReference(
id = R.string.hw_backup_google_drive_dialog_message
)
isTitleCorrect && isMessageCorrect
}
)
}
}
@Test
fun `WHEN onBackClick THEN router pop is called`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onBackClick()
verify { router.pop(onComplete = any()) }
}
private fun createModel(testScope: TestScope): WalletBackupModel {
return WalletBackupModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
getUserWalletUseCase = getUserWalletUseCase,
unlockHotWalletContextualUseCase = unlockHotWalletContextualUseCase,
router = router,
trackingContextProxy = trackingContextProxy,
analyticsEventHandler = analyticsEventHandler,
uiMessageSender = uiMessageSender,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}