diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml
index 45dbb43d03..4cd264c962 100644
--- a/core/res/src/main/res/values-pt-rBR/strings.xml
+++ b/core/res/src/main/res/values-pt-rBR/strings.xml
@@ -517,6 +517,7 @@
Foram encontrados fundos em endereços adicionais. Ative os Endereços Dinâmicos para acessá-los.
Fundos encontrados em endereços adicionais
Endereço dinâmico
+ O gerenciamento de endereços dinâmicos estará disponível assim que as transações pendentes estiverem na rede. %@ está completo
Melhores oportunidades
Limpar filtro
A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco.
@@ -595,6 +596,7 @@
Disponível em %s
Indisponível para este par
Permissão necessária
+ É necessária permissão.
Recomendado
Comprado %s
Comprando %s
@@ -641,6 +643,7 @@
A função Aprovar é necessária para conceder permissão a outro endereço para usar uma quantidade específica de seus tokens. Por definição, os contratos inteligentes não podem acessar seus tokens a menos que você aprove. Ao \"desbloquear\" seus tokens, você autoriza o contrato inteligente StakeKit a usá-los. Os mineradores da rede recebem uma taxa de gás (paga por você) para registrar essa ação no blockchain. Você pode fazer staking de seus tokens após conceder a aprovação.
Para continuar, você precisa permitir que o contrato inteligente da Polygon use seus dados. %s
Para continuar, conceda %1s permissão de contratos inteligentes para usar seu %2s
+ As corretoras descentralizadas exigem permissão para interagir com sua carteira. %1s
Conceder permissão
Ilimitado
Os endereços são gerados diretamente na sua carteira de hardware Tangem — prontos para usar e totalmente protegidos.
@@ -1115,6 +1118,14 @@
Esta transação já foi processada. Nenhuma ação adicional é necessária.
Obtendo as melhores taxas...
Instantâneo
+ A verificação é gratuita e geralmente leva de 1 a 2 minutos.
+ A Tangem não terá acesso às suas informações de identidade; você compartilha os dados diretamente com o provedor regulamentado.
+ A verificação desbloqueia o acesso total a transações futuras com este fornecedor.
+ Escolha outro método
+ Para cumprir os requisitos regulamentares locais %@ Requer verificação de identidade.
+ Verificação de identidade exigida pelo provedor de pagamento
+ Verificar
+ O que é importante
Ao utilizar a funcionalidade de acesso prioritário, você concorda com os termos do provedor. %1$s e %2$s
O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele.
O valor da compra não deve ser superior a %s
@@ -1181,6 +1192,8 @@
Cartão de crédito ou conta bancária
Compartilhe seu endereço ou código QR.
Entre seus portfólios
+ Outro
+ Recarga rápida
Não é necessário memorando
%1$s (%2$s) sobre %3$s rede
%1$s sobre %2$s rede
diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml
index 0a6290a4fb..b8e2e9e0e4 100644
--- a/core/res/src/main/res/values-ru/strings.xml
+++ b/core/res/src/main/res/values-ru/strings.xml
@@ -293,11 +293,13 @@
Удалить
Отключить
Отключено
+ Выключение
Отключить
Готово
Изменить
Включить
Включено
+ Включение
Ошибка
Комиссия сети
Обменять
@@ -347,6 +349,7 @@
- %dмин назад
месяц
+ Еще
Комиссия сети
Сумма отправки будет уменьшена на %1$s (%2$s) для покрытия выбранного уровня комиссии
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 9e3feaa6e5..403712fc0c 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -596,6 +596,7 @@
Available from %s
Unavailable for this pair
Permission Required
+ Permission needed
Recommended
Bought %s
Buying %s
@@ -643,6 +644,7 @@
The Approve function is needed to grant permission to another address to use a specific amount of your tokens. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the StakeKit smart contract to use them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can stake your token after giving approval.
To continue you need to allow Polygon smart contract to use your %s
To continue, grant %1s smart contracts permission to use your %2s
+ Decentralized exchanges require permission to interact with your wallet. %1s
Give Permission
Unlimited
Addresses are generated directly on your Tangem hardware wallet — ready to use and fully protected.
@@ -670,6 +672,8 @@
Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault.
If you do, you\'ll need to start over.
Recover existing wallet via Google Drive backup
+ We\'re working on Google Drive backup to make wallet recovery even easier.
+ Google Drive backup is coming soon
Google Drive backup
Create a secure wallet and transfer your funds for extra protection.
Create new wallet
diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt
index 850782d36a..b8c7e1c1f4 100644
--- a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt
+++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt
@@ -64,14 +64,18 @@ sealed class WalletSettingsAnalyticEvents(
event = "Button - Recovery phrase",
)
+ class ButtonGoogleDriveBackup : WalletSettingsAnalyticEvents(
+ event = "Button - Cloud Backup",
+ )
+
data class NoticeBackupFirst(
val source: String,
val action: Action,
) : WalletSettingsAnalyticEvents(
event = "Notice - Backup First",
params = mapOf(
- AnalyticsParam.Key.SOURCE to source,
- AnalyticsParam.Key.ACTION to action.value,
+ AnalyticsParam.SOURCE to source,
+ ACTION to action.value,
),
) {
enum class Action(val value: String) {
@@ -111,8 +115,8 @@ sealed class WalletSettingsAnalyticEvents(
) : WalletSettingsAnalyticEvents(
event = "Recovery Phrase Screen Info",
params = mapOf(
- AnalyticsParam.Key.SOURCE to source,
- AnalyticsParam.Key.ACTION to action,
+ AnalyticsParam.SOURCE to source,
+ ACTION to action,
),
)
@@ -122,8 +126,8 @@ sealed class WalletSettingsAnalyticEvents(
) : WalletSettingsAnalyticEvents(
event = "Recovery Phrase Screen",
params = mapOf(
- AnalyticsParam.Key.SOURCE to source,
- AnalyticsParam.Key.ACTION to action,
+ AnalyticsParam.SOURCE to source,
+ ACTION to action,
),
)
@@ -133,8 +137,8 @@ sealed class WalletSettingsAnalyticEvents(
) : WalletSettingsAnalyticEvents(
event = "Recovery Phrase Check",
params = mapOf(
- AnalyticsParam.Key.SOURCE to source,
- AnalyticsParam.Key.ACTION to action,
+ AnalyticsParam.SOURCE to source,
+ ACTION to action,
),
)
@@ -144,8 +148,8 @@ sealed class WalletSettingsAnalyticEvents(
) : WalletSettingsAnalyticEvents(
event = "Backup Complete Screen",
params = mapOf(
- AnalyticsParam.Key.SOURCE to source,
- AnalyticsParam.Key.ACTION to action,
+ AnalyticsParam.SOURCE to source,
+ ACTION to action,
),
)
@@ -153,14 +157,14 @@ sealed class WalletSettingsAnalyticEvents(
val source: String,
) : WalletSettingsAnalyticEvents(
event = "Access Code Screen Opened",
- params = mapOf(AnalyticsParam.Key.SOURCE to source),
+ params = mapOf(AnalyticsParam.SOURCE to source),
)
data class ReEnterAccessCodeScreen(
val source: String,
) : WalletSettingsAnalyticEvents(
event = "Re-enter Access Code Screen",
- params = mapOf(AnalyticsParam.Key.SOURCE to source),
+ params = mapOf(AnalyticsParam.SOURCE to source),
)
class ButtonStartUpgrade : WalletSettingsAnalyticEvents(
diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts
index 9d2eadef6e..5fb36dc68f 100644
--- a/features/hot-wallet/impl/build.gradle.kts
+++ b/features/hot-wallet/impl/build.gradle.kts
@@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.hotwallet.impl"
}
+tasks.withType().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)
}
\ No newline at end of file
diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt
similarity index 95%
rename from features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt
rename to features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt
index a1219ed46a..a7a4115f71 100644
--- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt
+++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt
@@ -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 {
diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt
index 32986a6549..6eb1b82ac3 100644
--- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt
+++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt
@@ -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)
diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt
index dc2787f9c6..59fb6abcdd 100644
--- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt
+++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt
@@ -132,7 +132,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider() } 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 { 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 { 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().right()
+
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ model.uiState.value.onRecoveryPhraseClick()
+ advanceUntilIdle()
+
+ verify { analyticsEventHandler.send(match { 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 { 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 { 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 { 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 { 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 { true }
+ )
+ }
+ verify {
+ uiMessageSender.send(
+ match {
+ 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,
+ )
+ }
+}
\ No newline at end of file