From 0c5d018677198aca94e2df901169a6bb4c1d71d3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 21 Aug 2025 19:56:51 +0500 Subject: [PATCH 001/132] Updated on 2026-08-14 --- .../tap/di/domain/WalletsDomainModule.kt | 22 ++++++++++- .../components/notifications/Notification.kt | 4 +- .../notifications/NotificationConfig.kt | 3 ++ .../res/drawable/ic_hardware_backup_36.xml | 22 +++++++++++ .../data/wallets/DefaultWalletsRepository.kt | 14 +++++++ .../wallets/repository/WalletsRepository.kt | 4 ++ ...DismissUpgradeWalletNotificationUseCase.kt | 12 ++++++ ...UpgradeWalletNotificationEnabledUseCase.kt | 13 +++++++ .../preview/PreviewWalletSettingsComponent.kt | 3 ++ .../entity/WalletSettingsItemUM.kt | 8 ++++ .../walletsettings/entity/WalletSettingsUM.kt | 1 + .../model/WalletSettingsModel.kt | 23 +++++++++++- .../walletsettings/ui/WalletSettingsScreen.kt | 37 +++++++++++++++++++ .../walletsettings/utils/ItemsBuilder.kt | 33 +++++++++++++++++ .../wallet/state/model/WalletNotification.kt | 2 + .../components/common/WalletNotifications.kt | 6 --- 16 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_hardware_backup_36.xml create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index 44dd702fc3..911eededb2 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -25,7 +25,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @Module @InstallIn(SingletonComponent::class) internal object WalletsDomainModule { @@ -352,4 +352,24 @@ internal object WalletsDomainModule { walletsRepository = walletsRepository, ) } + + @Provides + @Singleton + fun providesIsUpgradeWalletNotificationEnabledUseCase( + walletsRepository: WalletsRepository, + ): IsUpgradeWalletNotificationEnabledUseCase { + return IsUpgradeWalletNotificationEnabledUseCase( + walletsRepository = walletsRepository, + ) + } + + @Provides + @Singleton + fun providesDismissUpgradeWalletNotificationUseCase( + walletsRepository: WalletsRepository, + ): DismissUpgradeWalletNotificationUseCase { + return DismissUpgradeWalletNotificationUseCase( + walletsRepository = walletsRepository, + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 7b1baa0109..06a2bc1d9e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.* import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -64,7 +63,6 @@ fun Notification( NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention }, - iconSize: Dp = 20.dp, isEnabled: Boolean = true, ) { NotificationBaseContainer( @@ -78,7 +76,7 @@ fun Notification( MainContent( iconResId = config.iconResId, iconTint = iconTint, - iconSize = iconSize, + iconSize = config.iconSize, title = config.title, titleColor = titleColor, subtitle = config.subtitle, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt index 4afe96144e..ca5cdc8e5a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt @@ -1,6 +1,8 @@ package com.tangem.core.ui.components.notifications import androidx.annotation.DrawableRes +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.tangem.core.ui.extensions.TextReference /** @@ -26,6 +28,7 @@ data class NotificationConfig( val onCloseClick: (() -> Unit)? = null, val showArrowIcon: Boolean = onClick != null, val iconTint: IconTint = IconTint.Unspecified, + val iconSize: Dp = 20.dp, ) { sealed class ButtonsState { diff --git a/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml b/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml new file mode 100644 index 0000000000..071683ab08 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 2127d9b80b..d2a4f5cea1 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -31,6 +31,7 @@ import com.tangem.utils.coroutines.runCatching import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.collections.mutableSetOf typealias SeedPhraseNotificationsStatuses = Map @@ -44,6 +45,9 @@ internal class DefaultWalletsRepository( private val authProvider: AuthProvider, ) : WalletsRepository { + private val upgradeWalletNotificationDisabled: MutableStateFlow> = + MutableStateFlow(mutableSetOf()) + override suspend fun shouldSaveUserWalletsSync(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) } @@ -327,6 +331,16 @@ internal class DefaultWalletsRepository( } } + override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow { + return upgradeWalletNotificationDisabled.map { + it.contains(userWalletId) + } + } + + override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) { + upgradeWalletNotificationDisabled.update { it.plus(userWalletId) } + } + override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) { tangemTechApi.updateWallet( walletId = walletId, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index a1eb1f0cd8..b65e7006cc 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -57,6 +57,10 @@ interface WalletsRepository { suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean) + fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow + + suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) + @Throws suspend fun setWalletName(walletId: String, walletName: String) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt new file mode 100644 index 0000000000..2aeb755fba --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository + +class DismissUpgradeWalletNotificationUseCase( + private val walletsRepository: WalletsRepository, +) { + suspend operator fun invoke(userWalletId: UserWalletId) { + walletsRepository.dismissUpgradeWalletNotification(userWalletId) + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt new file mode 100644 index 0000000000..d38fec4fe2 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository +import kotlinx.coroutines.flow.Flow + +class IsUpgradeWalletNotificationEnabledUseCase( + private val walletsRepository: WalletsRepository, +) { + operator fun invoke(userWalletId: UserWalletId): Flow { + return walletsRepository.isUpgradeWalletNotificationEnabled(userWalletId) + } +} \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 453120249f..1ac9106632 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -45,6 +45,9 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { onNotificationsDescriptionClick = {}, isNotificationsPermissionGranted = false, onAccessCodeClick = {}, + walletUpgradeDismissed = false, + onUpgradeWalletClick = {}, + onDismissUpgradeWalletClick = {}, ), requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = {}, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 490fb2b00e..91a77aec05 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -43,4 +43,12 @@ internal sealed class WalletSettingsItemUM { val title: TextReference, val description: TextReference, ) : WalletSettingsItemUM() + + data class UpgradeWallet( + override val id: String, + val title: TextReference, + val description: TextReference, + val onClick: () -> Unit, + val onDismissClick: () -> Unit, + ) : WalletSettingsItemUM() } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt index 39bd16ae56..04014e4662 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt @@ -10,4 +10,5 @@ internal data class WalletSettingsUM( val requestPushNotificationsPermission: Boolean = false, val onPushNotificationPermissionGranted: (Boolean) -> Unit, val isWalletBackedUp: Boolean = true, + val walletUpgradeDismissed: Boolean = false, ) \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index f9d0b633b2..c190745304 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -56,7 +56,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class WalletSettingsModel @Inject constructor( getWalletUseCase: GetUserWalletUseCase, @@ -80,6 +80,8 @@ internal class WalletSettingsModel @Inject constructor( private val permissionsRepository: PermissionRepository, private val notificationsRepository: NotificationsRepository, private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, + private val isUpgradeWalletNotificationEnabledUseCase: IsUpgradeWalletNotificationEnabledUseCase, + private val dismissUpgradeWalletNotificationUseCase: DismissUpgradeWalletNotificationUseCase, ) : Model() { val params: WalletSettingsComponent.Params = paramsContainer.require() @@ -93,6 +95,7 @@ internal class WalletSettingsModel @Inject constructor( requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = ::onPushNotificationPermissionGranted, isWalletBackedUp = true, + walletUpgradeDismissed = false, ), ) @@ -120,7 +123,8 @@ internal class WalletSettingsModel @Inject constructor( getWalletUseCase.invokeFlow(params.userWalletId).distinctUntilChanged(), getWalletNFTEnabledUseCase.invoke(params.userWalletId), getWalletNotificationsEnabledUseCase(params.userWalletId), - ) { maybeWallet, nftEnabled, notificationsEnabled -> + isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), + ) { maybeWallet, nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled -> val wallet = maybeWallet.getOrNull() ?: return@combine val isRenameWalletAvailable = getShouldSaveUserWalletsSyncUseCase() val isWalletBackedUp = when (wallet) { @@ -139,6 +143,7 @@ internal class WalletSettingsModel @Inject constructor( isNotificationsEnabled = notificationsEnabled, isNotificationsFeatureEnabled = isNeedShowNotifications, isNotificationsPermissionGranted = isNotificationsPermissionGranted(), + isUpgradeNotificationEnabled = isUpgradeNotificationEnabled, ), isWalletBackedUp = isWalletBackedUp, ) @@ -165,6 +170,7 @@ internal class WalletSettingsModel @Inject constructor( isNotificationsFeatureEnabled: Boolean, isNotificationsEnabled: Boolean, isNotificationsPermissionGranted: Boolean, + isUpgradeNotificationEnabled: Boolean, ): PersistentList { val isMultiCurrency = when (userWallet) { is UserWallet.Cold -> userWallet.isMultiCurrency @@ -215,6 +221,9 @@ internal class WalletSettingsModel @Inject constructor( onCheckedNotificationsChanged = ::onCheckedNotificationsChange, onNotificationsDescriptionClick = ::onNotificationsDescriptionClick, onAccessCodeClick = ::onAccessCodeClick, + walletUpgradeDismissed = isUpgradeNotificationEnabled, + onUpgradeWalletClick = ::onUpgradeWalletClick, + onDismissUpgradeWalletClick = ::onDismissUpgradeWalletClick, ) } @@ -367,4 +376,14 @@ internal class WalletSettingsModel @Inject constructor( router.push(AppRoute.UpdateAccessCode(params.userWalletId)) } } + + private fun onUpgradeWalletClick() { + // TODO [REDACTED_TASK_KEY] + } + + private fun onDismissUpgradeWalletClick() { + modelScope.launch { + dismissUpgradeWalletNotificationUseCase.invoke(params.userWalletId) + } + } } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index fe95f8129a..8c4bbd6cf2 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -25,6 +25,7 @@ import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -116,6 +117,10 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) + is WalletSettingsItemUM.UpgradeWallet -> UpgradeWalletBlock( + modifier = itemModifier, + model = item, + ) } } } @@ -225,6 +230,21 @@ private fun SwitchBlock(model: WalletSettingsItemUM.WithSwitch, modifier: Modifi } } +@Composable +private fun UpgradeWalletBlock(model: WalletSettingsItemUM.UpgradeWallet, modifier: Modifier = Modifier) { + Notification( + config = NotificationConfig( + title = model.title, + subtitle = model.description, + iconResId = R.drawable.ic_hardware_backup_36, + iconSize = 36.dp, + onClick = model.onClick, + onCloseClick = model.onDismissClick, + ), + modifier = modifier, + ) +} + @Composable private fun NotificationAlertBlock(model: WalletSettingsItemUM.NotificationPermission, modifier: Modifier = Modifier) { Notification( @@ -266,4 +286,21 @@ private fun Preview_WalletSettingsScreen() { PreviewWalletSettingsComponent().Content(modifier = Modifier.fillMaxSize()) } } + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_WalletSettingsScreen1() { + TangemThemePreview { + UpgradeWalletBlock( + model = WalletSettingsItemUM.UpgradeWallet( + id = "upgrade_wallet", + title = stringReference("Upgrade wallet with a hardware backup"), + description = stringReference("Keep your crypto safe with Tangem’s best-in-class hardware wallet."), + onClick = {}, + onDismissClick = {}, + ), + ) + } +} // endregion Preview \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 5086f22579..4189d78f2a 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -47,8 +47,19 @@ internal class ItemsBuilder @Inject constructor( onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, onAccessCodeClick: () -> Unit, + walletUpgradeDismissed: Boolean, + onUpgradeWalletClick: () -> Unit, + onDismissUpgradeWalletClick: () -> Unit, ): PersistentList = persistentListOf() .add(buildNameItem(userWalletName, isRenameWalletAvailable, renameWallet)) + .addAll( + buildUpgradeWalletItem( + userWallet = userWallet, + walletUpgradeDismissed = walletUpgradeDismissed, + onUpgradeWalletClick = onUpgradeWalletClick, + onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, + ), + ) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) .add( buildCardItem( @@ -124,6 +135,28 @@ internal class ItemsBuilder @Inject constructor( onCheckedChange = onCheckedNFTChange, ) + private fun buildUpgradeWalletItem( + userWallet: UserWallet, + walletUpgradeDismissed: Boolean, + onUpgradeWalletClick: () -> Unit, + onDismissUpgradeWalletClick: () -> Unit, + ): List = when (userWallet) { + is UserWallet.Cold -> emptyList() + is UserWallet.Hot -> if (!walletUpgradeDismissed) { + listOf( + WalletSettingsItemUM.UpgradeWallet( + id = "upgrade_wallet", + title = resourceReference(id = R.string.hw_upgrade_to_cold_banner_title), + description = resourceReference(id = R.string.hw_upgrade_to_cold_banner_description), + onClick = onUpgradeWalletClick, + onDismissClick = onDismissUpgradeWalletClick, + ), + ) + } else { + emptyList() + } + } + private fun buildNotificationsPermissionItem() = WalletSettingsItemUM.NotificationPermission( id = "notifications_permission", title = resourceReference(id = R.string.transaction_notifications_warning_title), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index 965b55df30..3d23c93ae5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralReference @@ -282,6 +283,7 @@ sealed class WalletNotification(val config: NotificationConfig) { text = resourceReference(R.string.notification_referral_promo_button), onClick = onClick, ), + iconSize = 54.dp, ), ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 0227a5339a..211ce57e2d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NoteMigrationNotification import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.res.TangemTheme @@ -39,11 +38,6 @@ internal fun LazyListScope.notifications(configs: ImmutableList TangemTheme.colors.icon.warning is WalletNotification.Informational -> TangemTheme.colors.icon.accent From af7caffba5c63f83648f2a0469fc80f10274ddae Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 22 Aug 2025 09:07:42 +0000 Subject: [PATCH 002/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 418aa40d43..f430a4dd17 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1206" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 35fd75485cf1741a39249ce80832d7fb3801c6ef Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 22 Aug 2025 16:17:51 +0700 Subject: [PATCH 003/132] Updated on 2026-08-14 --- core/res/src/main/res/values-ja/strings.xml | 9 + core/res/src/main/res/values-ru/strings.xml | 10 + .../src/main/res/values-uk-rUA/strings.xml | 10 + core/res/src/main/res/values/strings.xml | 18 ++ .../archived/ArchivedAccountListModel.kt | 10 +- .../createedit/entity/AccountCreateEditUM.kt | 2 +- .../details/entity/AccountDetailsUM.kt | 2 +- .../wallet-settings/impl/build.gradle.kts | 1 + .../impl/DefaultWalletSettingsComponent.kt | 15 ++ .../preview/PreviewWalletSettingsComponent.kt | 37 ++++ .../entity/WalletSettingsItemUM.kt | 33 ++++ .../model/WalletSettingsModel.kt | 11 +- .../walletsettings/ui/WalletSettingsScreen.kt | 171 ++++++++++++++++-- .../utils/AccountItemsDelegate.kt | 111 ++++++++++++ .../walletsettings/utils/ItemsBuilder.kt | 3 + 15 files changed, 413 insertions(+), 30 deletions(-) create mode 100644 features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 2e061c0bce..43ea6c53bf 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -12,6 +12,7 @@ ウォレットのロックを解除するには、 %s桁のアクセスコードを設定します。 アクセスコードの作成 アクセスコード + アーカイブされたアカウント 回復する アーカイブ済み アカウントをアーカイブする @@ -26,6 +27,7 @@ 新しいアカウント アカウントを追加 アカウントを編集 + アカウントを長押しして並べ替える 編集を続ける 破棄 新しいアカウントを破棄してもよろしいですか? @@ -116,6 +118,12 @@ 30秒後に再試行するか、カードまたはリングをスキャンしてください 試行回数が多すぎます お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。 + プロモーションコードの処理中にエラーが発生しました。しばらくしてからもう一度お試しください。 + プロモーションコードが正常に有効化されました。14日以内に10 USDT相当のビットコインボーナスが付与されます。 + プロモーションコードが有効になりました + このプロモーションコードは既に使用されているため、再度使うことはできません。 + このプロモーションコードは無効であり、有効化できません。 + ボーナスを受け取るにはビットコインアドレスが必要です。ウォレットにビットコインアドレスを追加し、再度アクティベーションをお試しください。 バックアップ処理を開始する 銀行カードまたはその他の支払い方法を使用する @@ -149,6 +157,7 @@ ADAが不足しています。 受け入れる アクセスが拒否されました + アカウント 追加 ポートフォリオに追加 トークンを追加 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index befe0fd2d7..899b661b01 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -59,6 +59,16 @@ Пожалуйста, попробуйте снова через 30 секунд или отсканируйте карту или кольцо Слишком много попыток Вы отключили биометрическую аутентификацию на вашем телефоне и не сможете сохранять кошельки в приложении. Для сохранения кошельков, пожалуйста, включите функцию биометрической аутентификации в настройках телефона. + При обработке промокода произошла ошибка. Пожалуйста, попробуйте позже. + Ошибка активации + Ваш промокод успешно активирован. Бонус 10 USDT в Bitcoin будет зачислен через 14 дней. + Промокод активирован + Этот промокод уже был использован и не может быть активирован повторно. + Код недоступен + Этот промокод недействителен и не может быть активирован. + Неверный код + Для зачисления бонуса нужен Bitcoin-адрес. Добавьте его в портфель и повторите активацию. + Требуется Bitcoin-адрес Начать резервное копирование Используйте банковскую карту или другие методы оплаты diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 5c7d9f4ef4..21880f895f 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -59,6 +59,16 @@ Будь ласка, спробуйте знову через 30 секунд або відскануйте картку або кільце Забагато спроб Ви вимкнули біометричну автентифікацію на своєму телефоні і не зможете зберігати гаманці в додатку. Щоб зберегти гаманці, будь ласка, увімкніть функцію біометричної автентифікації в налаштуваннях телефону. + Під час обробки промокоду сталася помилка. Будь ласка, спробуйте пізніше. + Помилка активації + Ваш промокод успішно активовано. Бонус 10 USDT у Bitcoin буде зараховано через 14 днів. + Промокод активовано + Цей промокод уже був використаний і не може бути активований повторно. + Код недоступний + Цей промокод недійсний і не може бути активований. + Невірний код + Для зарахування бонусу потрібна Bitcoin-адреса. Додайте її до портфеля та повторіть активацію. + Потрібна Bitcoin-адреса Почніть процес резервного копіювання Використовуйте банківську картку або інші способи оплати diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index dc9ced8f5c..0db4cc4be9 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -12,7 +12,12 @@ Set a %s-digit Access Code to unlock your wallet. Create Access Code Access code + You cannot create more than %1$s accounts. Archive one to add new. + Can’t add new account + Archived accounts Recover + You’re about to recover “%1$s”. + Recover account Archived Archive account Archive @@ -26,6 +31,8 @@ New account Add account Edit account + %1$s in %2$s + Long tap on an account to reorder accounts Keep Editing Discard Are you sure you want to discard new account? @@ -117,6 +124,12 @@ Please try again in 30 seconds or scan the card or ring Too many attempts You have disabled biometric authentication on your phone and will not be able to save wallets in the app. To save wallets, please enable the biometric authentication function in your phone settings. + An error occurred while processing your promo code. Please try again later. + Your promo code has been successfully activated. A bonus of 10 USDT in Bitcoin will be credited in 14 days. + Promo code activated + This promo code has already been used and cannot be activated again. + This promo code is not valid and cannot be activated. + A Bitcoin address is required to receive the bonus. Please add one to your wallet and retry the activation. Start backup process Use a bank card or other payment methods @@ -152,6 +165,7 @@ Not enough ADA Accept Access denied + Accounts Add Add to portfolio Add token @@ -239,6 +253,10 @@ month Network fee Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level + + %d network + %d networks + Next NFT No diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index a93cdcca2b..95554e161b 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -5,9 +5,8 @@ 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.res.R -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase @@ -48,8 +47,11 @@ internal class ArchivedAccountListModel @Inject constructor( ) messageSender.send( DialogMessage( - title = stringReference(account.accountName.value), - message = TextReference.EMPTY, + title = resourceReference(R.string.account_archived_recover_dialog_title), + message = resourceReference( + id = R.string.account_archived_recover_dialog_description, + formatArgs = wrappedList(account.accountName.value), + ), firstActionBuilder = { firstAction }, secondActionBuilder = { secondAction }, ), diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt index 330fc2352f..4b4365ba95 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList -data class AccountCreateEditUM( +internal data class AccountCreateEditUM( val title: TextReference, val account: Account, val colorsState: Colors, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt index c68a1f098a..799c295838 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt @@ -2,7 +2,7 @@ package com.tangem.features.account.details.entity import com.tangem.common.ui.account.CryptoPortfolioIconUM -data class AccountDetailsUM( +internal data class AccountDetailsUM( val accountName: String, val accountIcon: CryptoPortfolioIconUM, val onCloseClick: () -> Unit, diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index b69105d2c8..3f34abd5a1 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.common.routing) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.legacy) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt index 49f23d0f8c..1cb8c2c145 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.component.impl import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -13,6 +14,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableDialogComponent +import com.tangem.core.ui.utils.requestPermission import com.tangem.feature.walletsettings.component.NetworksAvailableForNotificationsComponent import com.tangem.feature.walletsettings.component.RenameWalletComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent @@ -20,6 +22,7 @@ import com.tangem.feature.walletsettings.entity.DialogConfig import com.tangem.feature.walletsettings.entity.NetworksAvailableForNotificationBSConfig import com.tangem.feature.walletsettings.model.WalletSettingsModel import com.tangem.feature.walletsettings.ui.WalletSettingsScreen +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -61,6 +64,18 @@ internal class DefaultWalletSettingsComponent @AssistedInject constructor( dialog = { dialog.child?.instance?.Dialog() }, ) + val requestPushPermission = requestPermission( + onAllow = { state.onPushNotificationPermissionGranted(true) }, + onDeny = { state.onPushNotificationPermissionGranted(false) }, + permission = PUSH_PERMISSION, + ) + + if (state.requestPushNotificationsPermission) { + LaunchedEffect(Unit) { + requestPushPermission() + } + } + bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 1ac9106632..69bf4cfb75 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -2,12 +2,19 @@ package com.tangem.feature.walletsettings.component.preview import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.core.analytics.DummyAnalyticsEventHandler import com.tangem.core.decompose.navigation.DummyRouter +import com.tangem.core.ui.components.block.model.BlockUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.walletsettings.component.WalletSettingsComponent +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM +import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.ui.WalletSettingsScreen import com.tangem.feature.walletsettings.utils.ItemsBuilder import com.tangem.hot.sdk.model.HotWalletId @@ -48,11 +55,41 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { walletUpgradeDismissed = false, onUpgradeWalletClick = {}, onDismissUpgradeWalletClick = {}, + accountsUM = previewAccounts(), ), requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = {}, ) + private fun previewAccounts() = buildList { + WalletSettingsAccountsUM.Header( + id = "accounts_header", + text = resourceReference(R.string.common_accounts), + ).let(::add) + WalletSettingsAccountsUM.Account( + id = "accountId", + accountName = stringReference("Main account"), + accountIconUM = AccountIconPreviewData.randomAccountIcon(), + tokensInfo = stringReference("10 tokens"), + networksInfo = stringReference("2 networks"), + onClick = {}, + ).let(::add) + WalletSettingsAccountsUM.Footer( + id = "accounts_footer", + addAccount = AddAccountUM( + title = resourceReference(R.string.account_form_title_create), + addAccountEnabled = true, + onAddAccountClick = {}, + ), + archivedAccounts = BlockUM( + text = resourceReference(R.string.account_archived_accounts), + iconRes = R.drawable.ic_archive_24, + onClick = {}, + ), + description = resourceReference(R.string.account_reorder_description), + ).let(::add) + } + @Composable override fun Content(modifier: Modifier) { WalletSettingsScreen( diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 91a77aec05..85a6bcb784 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.entity import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -51,4 +52,36 @@ internal sealed class WalletSettingsItemUM { val onClick: () -> Unit, val onDismissClick: () -> Unit, ) : WalletSettingsItemUM() +} + +@Immutable +internal sealed class WalletSettingsAccountsUM : WalletSettingsItemUM() { + + data class Header( + override val id: String, + val text: TextReference, + ) : WalletSettingsAccountsUM() + + data class Account( + override val id: String, + val accountName: TextReference, + val accountIconUM: CryptoPortfolioIconUM, + val tokensInfo: TextReference, + val networksInfo: TextReference, + val onClick: () -> Unit, + ) : WalletSettingsAccountsUM() + + data class Footer( + override val id: String, + val addAccount: AddAccountUM, + val archivedAccounts: BlockUM, + val description: TextReference, + ) : WalletSettingsAccountsUM() { + + data class AddAccountUM( + val title: TextReference, + val addAccountEnabled: Boolean, + val onAddAccountClick: () -> Unit, + ) + } } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index c190745304..1a5e3ee067 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -15,11 +15,7 @@ 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.settings.SettingsManager -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.icon -import com.tangem.core.ui.components.bottomsheets.message.infoBlock -import com.tangem.core.ui.components.bottomsheets.message.onClick -import com.tangem.core.ui.components.bottomsheets.message.secondaryButton +import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction @@ -29,6 +25,7 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.DisableWalletNFTUseCase import com.tangem.domain.nft.EnableWalletNFTUseCase import com.tangem.domain.nft.GetWalletNFTEnabledUseCase @@ -36,7 +33,6 @@ import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUse import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.repositories.PermissionRepository -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.* import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.analytics.WalletSettingsAnalyticEvents @@ -46,6 +42,7 @@ import com.tangem.feature.walletsettings.entity.NetworksAvailableForNotification import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R +import com.tangem.feature.walletsettings.utils.AccountItemsDelegate import com.tangem.feature.walletsettings.utils.ItemsBuilder import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -65,6 +62,7 @@ internal class WalletSettingsModel @Inject constructor( private val messageSender: UiMessageSender, private val deleteWalletUseCase: DeleteWalletUseCase, private val itemsBuilder: ItemsBuilder, + private val accountItemsDelegate: AccountItemsDelegate, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsContextProxy: AnalyticsContextProxy, @@ -224,6 +222,7 @@ internal class WalletSettingsModel @Inject constructor( walletUpgradeDismissed = isUpgradeNotificationEnabled, onUpgradeWalletClick = ::onUpgradeWalletClick, onDismissUpgradeWalletClick = ::onDismissUpgradeWalletClick, + accountsUM = with(accountItemsDelegate) { listOf() }, // todo account ) } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index 8c4bbd6cf2..b18c80f1f0 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -2,20 +2,29 @@ package com.tangem.feature.walletsettings.ui import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountRow +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.TangemSwitch import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM @@ -30,12 +39,11 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.WalletSettingsScreenTestTags -import com.tangem.core.ui.utils.requestPermission import com.tangem.feature.walletsettings.component.preview.PreviewWalletSettingsComponent +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R -import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION @Composable internal fun WalletSettingsScreen( @@ -71,7 +79,6 @@ internal fun WalletSettingsScreen( private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { LazyColumn( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), contentPadding = PaddingValues( top = TangemTheme.dimens.spacing16, bottom = TangemTheme.dimens.spacing16, @@ -89,8 +96,17 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { items = state.items, key = WalletSettingsItemUM::id, ) { item -> - val itemModifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) + val offsetModifier = when (item) { + is WalletSettingsAccountsUM.Account, + is WalletSettingsAccountsUM.Footer, + -> Modifier.padding(horizontal = TangemTheme.dimens.spacing16) + else -> Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing16, + ) + } + val itemModifier = offsetModifier .fillMaxWidth() .testTag(WalletSettingsScreenTestTags.SCREEN_ITEM) @@ -121,21 +137,12 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) + is WalletSettingsAccountsUM.Header -> AccountsHeader(item, itemModifier) + is WalletSettingsAccountsUM.Account -> AccountItem(item, itemModifier) + is WalletSettingsAccountsUM.Footer -> AccountsFooter(item, itemModifier) } } } - - val requestPushPermission = requestPermission( - onAllow = { state.onPushNotificationPermissionGranted(true) }, - onDeny = { state.onPushNotificationPermissionGranted(false) }, - permission = PUSH_PERMISSION, - ) - - if (state.requestPushNotificationsPermission) { - LaunchedEffect(Unit) { - requestPushPermission() - } - } } @Composable @@ -257,6 +264,134 @@ private fun NotificationAlertBlock(model: WalletSettingsItemUM.NotificationPermi ) } +@Composable +private fun AccountsHeader(model: WalletSettingsAccountsUM.Header, modifier: Modifier = Modifier) { + Text( + modifier = modifier + .background( + shape = RoundedCornerShape( + topStart = TangemTheme.dimens.radius16, + topEnd = TangemTheme.dimens.radius16, + ), + color = TangemTheme.colors.background.primary, + ) + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing4, + ), + text = model.text.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun AccountItem(model: WalletSettingsAccountsUM.Account, modifier: Modifier = Modifier) { + val subtitle = stringResourceSafe( + id = R.string.account_label_tokens_info, + formatArgs = arrayOf( + model.tokensInfo.resolveReference(), + model.networksInfo.resolveReference(), + ), + ) + AccountRow( + modifier = modifier + .background(color = TangemTheme.colors.background.primary) + .clickable(onClick = model.onClick) + .padding(12.dp), + title = model.accountName, + subtitle = stringReference(subtitle), + icon = model.accountIconUM, + ) +} + +@Composable +private fun AccountsFooter(model: WalletSettingsAccountsUM.Footer, modifier: Modifier = Modifier) { + Column(modifier) { + Column( + modifier = Modifier.background( + shape = RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ), + color = TangemTheme.colors.background.primary, + ), + ) { + AddAccountRow(model.addAccount) + SpacerH( + height = TangemTheme.dimens.size0_5, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing12) + .background(TangemTheme.colors.stroke.primary), + ) + BlockItem( + modifier = Modifier.fillMaxWidth(), + model = model.archivedAccounts, + ) + } + SpacerH8() + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing12), + text = model.description.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun AddAccountRow(model: WalletSettingsAccountsUM.Footer.AddAccountUM, modifier: Modifier = Modifier) { + val iconTint: Color + val backgroundColor: Color + val textColor: Color + if (model.addAccountEnabled) { + iconTint = TangemTheme.colors.icon.accent + backgroundColor = TangemTheme.colors.icon.accent.copy(alpha = 0.1f) + textColor = TangemTheme.colors.text.accent + } else { + iconTint = TangemTheme.colors.icon.inactive + backgroundColor = TangemTheme.colors.field.primary + textColor = TangemTheme.colors.text.disabled + } + Row( + modifier = modifier + .clickable(onClick = model.onAddAccountClick) + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(backgroundColor) + .clickable(onClick = { model.onAddAccountClick() }), + ) { + Icon( + modifier = Modifier.size(18.dp), + tint = iconTint, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_plus_24), + contentDescription = null, + ) + } + + Text( + text = model.title.resolveReference(), + color = textColor, + style = TangemTheme.typography.subtitle1, + ) + } +} + @Composable private fun DescriptionWithMoreBlock( model: WalletSettingsItemUM.DescriptionWithMore, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt new file mode 100644 index 0000000000..7e9939d2e3 --- /dev/null +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt @@ -0,0 +1,111 @@ +package com.tangem.feature.walletsettings.utils + +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.account.toUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.block.model.BlockUM +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM +import com.tangem.feature.walletsettings.impl.R +import javax.inject.Inject + +@ModelScoped +internal class AccountItemsDelegate @Inject constructor( + private val router: Router, + private val messageSender: UiMessageSender, +) { + + fun buildUiList(userWalletId: UserWalletId, accounts: List): List = buildList { + WalletSettingsAccountsUM.Header( + id = "accounts_header", + text = resourceReference(R.string.common_accounts), + ).let(::add) + + addAll(accounts.map(::mapAccount)) + + val addAccountEnabled = true // todo account + WalletSettingsAccountsUM.Footer( + id = "accounts_footer", + addAccount = AddAccountUM( + title = resourceReference(R.string.account_form_title_create), + addAccountEnabled = addAccountEnabled, + onAddAccountClick = { + if (addAccountEnabled) openAddAccount(userWalletId) else canNotAddAccountDialog() + }, + ), + archivedAccounts = BlockUM( + text = resourceReference(R.string.account_archived_accounts), + iconRes = R.drawable.ic_archive_24, + onClick = { openArchivedAccounts(userWalletId) }, + ), + description = resourceReference(R.string.account_reorder_description), + ).let(::add) + } + + private fun mapAccount(account: Account): WalletSettingsAccountsUM = when (account) { + is Account.CryptoPortfolio -> account.mapCryptoPortfolio() + } + + private fun Account.CryptoPortfolio.mapCryptoPortfolio(): WalletSettingsAccountsUM { + return WalletSettingsAccountsUM.Account( + id = accountId.value, + accountName = stringReference(accountName.value), + accountIconUM = icon.toUM(), + tokensInfo = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + networksInfo = pluralReference( + R.plurals.common_networks_count, + count = networksCount, + formatArgs = wrappedList(networksCount), + ), + onClick = { openAccountDetails(this) }, + ) + } + + private fun openAccountDetails(account: Account) { + router.push(AppRoute.AccountDetails(account)) + } + + private fun openArchivedAccounts(userWalletId: UserWalletId) { + router.push(AppRoute.ArchivedAccountList(userWalletId)) + } + + private fun openAddAccount(userWalletId: UserWalletId) { + router.push(AppRoute.CreateAccount(userWalletId)) + } + + private fun canNotAddAccountDialog() { + val firstAction = EventMessageAction( + title = resourceReference(R.string.common_got_it), + onClick = { }, + ) + messageSender.send( + DialogMessage( + title = resourceReference(R.string.account_add_limit_dialog_title), + message = resourceReference( + id = R.string.account_add_limit_dialog_description, + formatArgs = wrappedList(MAX_ACCOUNT_COUNT.toString()), + ), + firstActionBuilder = { firstAction }, + ), + ) + } + + companion object { + // todo account use domain const? + private const val MAX_ACCOUNT_COUNT = 20 + } +} \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 4189d78f2a..d26dc7fd98 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.walletsettings.analytics.Settings +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.impl.R import com.tangem.hot.sdk.model.HotWalletId @@ -30,6 +31,7 @@ internal class ItemsBuilder @Inject constructor( fun buildItems( userWallet: UserWallet, userWalletName: String, + accountsUM: List, isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, isManageTokensAvailable: Boolean, @@ -60,6 +62,7 @@ internal class ItemsBuilder @Inject constructor( onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, ), ) + .addAll(accountsUM) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) .add( buildCardItem( From 3e018452037eddaf01e0df62c813d5aeb70cb384 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 19 Aug 2025 16:14:54 +0400 Subject: [PATCH 004/132] Updated on 2026-08-14 --- data/account/build.gradle.kts | 16 +- .../account/converter/AccountConvertersExt.kt | 33 ++++ .../account/converter/AccountListConverter.kt | 46 +++++ .../converter/ArchivedAccountConverter.kt | 29 ++++ .../converter/CryptoPortfolioConverter.kt | 61 +++++++ .../converter/CryptoPortfolioIconConverter.kt | 21 +++ .../GetWalletAccountsResponseConverter.kt | 44 +++++ .../SaveWalletAccountsResponseConverter.kt | 33 ++++ .../converter/TokensGroupTypeConverter.kt | 29 ++++ .../converter/TokensSortTypeConverter.kt | 29 ++++ .../account/converter/AccountConverterExt.kt | 85 ++++++++++ .../converter/AccountListConverterTest.kt | 159 ++++++++++++++++++ .../converter/ArchivedAccountConverterTest.kt | 144 ++++++++++++++++ .../converter/CryptoPortfolioConverterTest.kt | 155 +++++++++++++++++ .../CryptoPortfolioIconConverterTest.kt | 66 ++++++++ .../GetWalletAccountsResponseConverterTest.kt | 130 ++++++++++++++ ...SaveWalletAccountsResponseConverterTest.kt | 50 ++++++ .../converter/TokensGroupTypeConverterTest.kt | 85 ++++++++++ .../converter/TokensSortTypeConverterTest.kt | 81 +++++++++ .../currency/UserTokensResponseFactory.kt | 3 +- .../tangem/domain/models/account/AccountId.kt | 33 ++++ 21 files changed, 1330 insertions(+), 2 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 464ba8486e..287f2b190f 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -9,9 +9,14 @@ android { namespace = "com.tangem.data.account" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // region Project - Core + implementation(projects.core.datasource) api(projects.core.utils) // endregion @@ -21,7 +26,7 @@ dependencies { // endregion // Project - Data - implementation(projects.core.datasource) + implementation(projects.data.common) // endregion // region DI @@ -34,4 +39,13 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.timber) // endregion + + // region Test + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) + // endregion } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt new file mode 100644 index 0000000000..ab8eef91a8 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt @@ -0,0 +1,33 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId + +internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { + return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse { + error("Unable to create AccountId from value: $this. Cause: $it") + } +} + +internal fun String.toAccountName(): AccountName { + return AccountName(value = this).getOrElse { + error("Unable to create AccountName from value: $this. Cause: $it") + } +} + +internal fun WalletAccountDTO.toIcon(): CryptoPortfolioIcon { + return CryptoPortfolioIconConverter.convert( + value = CryptoPortfolioIconConverter.DataModel(icon = icon, color = iconColor), + ) +} + +internal fun Int.toDerivationIndex(): DerivationIndex { + return DerivationIndex(value = this).getOrElse { + error("Unable to create DerivationIndex from value: $this. Cause: $it") + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt new file mode 100644 index 0000000000..d4c993f59f --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt @@ -0,0 +1,46 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.Converter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Converts a [GetWalletAccountsResponse] to an [AccountList] and vice versa + * + * @property userWallet the user wallet associated with the account list + * @param cryptoPortfolioConverterFactory factory to create [CryptoPortfolioConverter] instances + * +[REDACTED_AUTHOR] + */ +internal class AccountListConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory, +) : Converter { + + private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy { + cryptoPortfolioConverterFactory.create(userWallet) + } + + override fun convert(value: GetWalletAccountsResponse): AccountList { + return AccountList( + userWallet = userWallet, + accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(), + totalAccounts = value.wallet.totalAccounts, + sortType = TokensSortTypeConverter.convert(value.wallet.sort), + groupType = TokensGroupTypeConverter.convert(value.wallet.group), + ) + .getOrElse { + error("Failed to convert GetWalletAccountsResponse to AccountList: $it") + } + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet): AccountListConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt new file mode 100644 index 0000000000..072c82e3fc --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.converter.Converter + +/** + * Converts a [WalletAccountDTO] to an [ArchivedAccount] + * + * @param userWalletId the ID of the user wallet associated with the account + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountConverter( + private val userWalletId: UserWalletId, +) : Converter { + + override fun convert(value: WalletAccountDTO): ArchivedAccount { + return ArchivedAccount( + accountId = value.id.toAccountId(userWalletId = userWalletId), + name = value.name.toAccountName(), + icon = value.toIcon(), + derivationIndex = value.derivationIndex.toDerivationIndex(), + tokensCount = value.totalTokens ?: error("Total tokens should not be null"), + networksCount = value.totalNetworks ?: error("Total networks should not be null"), + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt new file mode 100644 index 0000000000..bae26707a6 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt @@ -0,0 +1,61 @@ +package com.tangem.data.account.converter + +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.TwoWayConverter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Converts a [WalletAccountDTO] to an [Account.CryptoPortfolio] and vise versa + * + * @property userWallet the user wallet associated with the account list + * @property responseCryptoCurrenciesFactory factory to create crypto currencies from response tokens + * +[REDACTED_AUTHOR] + */ +internal class CryptoPortfolioConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val userTokensResponseFactory: UserTokensResponseFactory, +) : TwoWayConverter { + + override fun convert(value: WalletAccountDTO): Account.CryptoPortfolio { + val tokens = value.tokens ?: error("Tokens should not be null") + + return Account.CryptoPortfolio( + accountId = value.id.toAccountId(userWallet.walletId), + accountName = value.name.toAccountName(), + icon = value.toIcon(), + derivationIndex = value.derivationIndex.toDerivationIndex(), + cryptoCurrencies = if (tokens.isNotEmpty()) { + responseCryptoCurrenciesFactory.createCurrencies( + tokens = tokens, + userWallet = userWallet, + ).toSet() + } else { + emptySet() + }, + ) + } + + override fun convertBack(value: Account.CryptoPortfolio): WalletAccountDTO { + return WalletAccountDTO( + id = value.accountId.value, + name = value.accountName.value, + derivationIndex = value.derivationIndex.value, + icon = value.icon.value.name, + iconColor = value.icon.color.name, + tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken), + ) + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet): CryptoPortfolioConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt new file mode 100644 index 0000000000..33b8b2ab20 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.data.account.converter + +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.utils.converter.Converter + +/** + * Converts a [CryptoPortfolioIconConverter.DataModel] to a [CryptoPortfolioIcon] + * +[REDACTED_AUTHOR] + */ +internal object CryptoPortfolioIconConverter : Converter { + + override fun convert(value: DataModel): CryptoPortfolioIcon { + return CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.valueOf(value.icon), + color = CryptoPortfolioIcon.Color.valueOf(value.color), + ) + } + + data class DataModel(val icon: String, val color: String) +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt new file mode 100644 index 0000000000..4ae6bf8b41 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt @@ -0,0 +1,44 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.Converter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** +[REDACTED_AUTHOR] + */ +internal class GetWalletAccountsResponseConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + @Assisted val version: Int, + cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory, +) : Converter { + + private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy { + cryptoPortfolioConverterFactory.create(userWallet) + } + + override fun convert(value: AccountList): GetWalletAccountsResponse { + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = version, + group = TokensGroupTypeConverter.convertBack(value.groupType), + sort = TokensSortTypeConverter.convertBack(value.sortType), + totalAccounts = value.totalAccounts, + ), + accounts = value.accounts + .filterIsInstance() + .map(cryptoPortfolioConverter::convertBack), + unassignedTokens = emptyList(), + ) + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet, version: Int): GetWalletAccountsResponseConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt new file mode 100644 index 0000000000..25c5307b84 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt @@ -0,0 +1,33 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.utils.converter.Converter + +/** + * Converts an [AccountList] to a [SaveWalletAccountsResponse] + * +[REDACTED_AUTHOR] + */ +internal object SaveWalletAccountsResponseConverter : Converter { + + override fun convert(value: AccountList): SaveWalletAccountsResponse { + return SaveWalletAccountsResponse( + accounts = value.accounts + .filterIsInstance() + .map(::toDTO), + ) + } + + private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO { + return WalletAccountDTO( + id = account.accountId.value, + name = account.accountName.value, + derivationIndex = account.derivationIndex.value, + icon = account.icon.value.name, + iconColor = account.icon.color.name, + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt new file mode 100644 index 0000000000..9a9b92af1b --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensGroupType +import com.tangem.utils.converter.TwoWayConverter + +/** + * Converts a [UserTokensResponse.GroupType] to a [TokensGroupType] and vice versa + * +[REDACTED_AUTHOR] + */ +internal object TokensGroupTypeConverter : TwoWayConverter { + + override fun convert(value: UserTokensResponse.GroupType): TokensGroupType { + return when (value) { + UserTokensResponse.GroupType.NETWORK -> TokensGroupType.NETWORK + UserTokensResponse.GroupType.NONE, + UserTokensResponse.GroupType.TOKEN, + -> TokensGroupType.NONE + } + } + + override fun convertBack(value: TokensGroupType): UserTokensResponse.GroupType { + return when (value) { + TokensGroupType.NONE -> UserTokensResponse.GroupType.NONE + TokensGroupType.NETWORK -> UserTokensResponse.GroupType.NETWORK + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt new file mode 100644 index 0000000000..91b2b2e88a --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensSortType +import com.tangem.utils.converter.TwoWayConverter + +/** + * Converts a [UserTokensResponse.SortType] to a [TokensSortType] and vice versa + * +[REDACTED_AUTHOR] + */ +internal object TokensSortTypeConverter : TwoWayConverter { + + override fun convert(value: UserTokensResponse.SortType): TokensSortType { + return when (value) { + UserTokensResponse.SortType.BALANCE -> TokensSortType.BALANCE + UserTokensResponse.SortType.MANUAL, + UserTokensResponse.SortType.MARKETCAP, + -> TokensSortType.NONE + } + } + + override fun convertBack(value: TokensSortType): UserTokensResponse.SortType { + return when (value) { + TokensSortType.NONE -> UserTokensResponse.SortType.MANUAL + TokensSortType.BALANCE -> UserTokensResponse.SortType.BALANCE + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt new file mode 100644 index 0000000000..5670802776 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt @@ -0,0 +1,85 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +internal fun createWalletAccountDTO( + userWalletId: UserWalletId, + accountId: String? = null, + accountName: String? = null, + icon: String? = null, + iconColor: String? = null, + derivationIndex: Int? = null, + tokens: List? = emptyList(), +): WalletAccountDTO { + val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) + + return WalletAccountDTO( + id = accountId ?: mainAccount.accountId.value, + name = accountName ?: mainAccount.accountName.value, + derivationIndex = derivationIndex ?: mainAccount.derivationIndex.value, + icon = icon ?: mainAccount.icon.value.name, + iconColor = iconColor ?: mainAccount.icon.color.name, + tokens = tokens, + ) +} + +internal fun createCryptoPortfolio(userWalletId: UserWalletId): Account.CryptoPortfolio { + return Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) +} + +internal fun createGetWalletAccountsResponse( + userWalletId: UserWalletId, + groupType: UserTokensResponse.GroupType = UserTokensResponse.GroupType.NETWORK, + sortType: UserTokensResponse.SortType = UserTokensResponse.SortType.BALANCE, + accountId: String? = null, + accountName: String? = null, + icon: String? = null, + iconColor: String? = null, + derivationIndex: Int? = null, + tokens: List? = emptyList(), +): GetWalletAccountsResponse { + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 0, + group = groupType, + sort = sortType, + totalAccounts = 1, + ), + accounts = buildList { + createWalletAccountDTO( + userWalletId = userWalletId, + accountId = accountId, + accountName = accountName, + icon = icon, + iconColor = iconColor, + derivationIndex = derivationIndex, + tokens = tokens, + ) + .let(::add) + }, + unassignedTokens = emptyList(), + ) +} + +internal fun createAccountList( + userWallet: UserWallet, + sortType: TokensSortType = TokensSortType.BALANCE, + groupType: TokensGroupType = TokensGroupType.NETWORK, +): AccountList { + return AccountList( + userWallet = userWallet, + accounts = setOf(createCryptoPortfolio(userWallet.walletId)), + totalAccounts = 1, + sortType = sortType, + groupType = groupType, + ) + .getOrNull()!! +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt new file mode 100644 index 0000000000..8b32adb5e5 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt @@ -0,0 +1,159 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountListConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + private val cryptoPortfolioConverterFactory = mockk() + private val cryptoPortfolioConverter = mockk() + private val converter = AccountListConverter(userWallet, cryptoPortfolioConverterFactory) + + @BeforeAll + fun setupAll() { + every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + clearMocks(cryptoPortfolioConverter) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `cryptoPortfolioConverter throws exception`() { + // Arrange + val dto = createGetWalletAccountsResponse(userWallet.walletId) + val exception = IllegalStateException("Test exception") + + every { cryptoPortfolioConverter.convert(any()) } throws exception + + // Act + val actual = runCatching { converter.convert(dto) }.exceptionOrNull()!! + + // Asset + val expected = exception + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual.message).isEqualTo(expected.message) + } + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Arrange + if (model.expected.isSuccess) { + model.value.accounts.forEach { dto -> + val account = model.expected.getOrNull()!!.accounts + .firstOrNull { it.accountId.value == dto.id } as? Account.CryptoPortfolio + + every { cryptoPortfolioConverter.convert(dto) } returns account!! + } + } + + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.BALANCE, + groupType = UserTokensResponse.GroupType.NETWORK, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ), + ), + ), + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MANUAL, + groupType = UserTokensResponse.GroupType.TOKEN, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + ), + ), + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MARKETCAP, + groupType = UserTokensResponse.GroupType.NONE, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + ), + ), + ConvertModel( + value = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 0, + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 1, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ), + expected = Result.failure( + IllegalStateException( + "Failed to convert GetWalletAccountsResponse to AccountList: EmptyAccountsList: " + + "The accounts list cannot be empty", + ), + ), + ), + ) + } + } + + data class ConvertModel( + val value: GetWalletAccountsResponse, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt new file mode 100644 index 0000000000..90831c1d3b --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt @@ -0,0 +1,144 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountConverterTest { + + private val userWalletId = UserWalletId("011") + private val converter = ArchivedAccountConverter(userWalletId = userWalletId) + + @ParameterizedTest + @ProvideTestModels + fun convert(model: TestModel) { + // Act + val actual = runCatching { converter.convert(value = model.value) } + + // Assert + actual + .onSuccess { + val expected = model.expected.getOrNull()!! + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + TestModel( + value = createDTO(), + expected = Result.success(createDomain()), + ), + TestModel( + value = createDTO(accountId = "123"), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}", + ), + ), + ), + TestModel( + value = createDTO(name = ""), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}", + ), + ), + ), + TestModel( + value = createDTO(icon = "INVALID_ICON"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + TestModel( + value = createDTO(iconColor = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + TestModel( + value = createDTO(derivationIndex = -1), + expected = Result.failure( + IllegalStateException( + "Unable to create DerivationIndex from value: -1. " + + "Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1", + ), + ), + ), + TestModel( + value = createDTO(totalTokens = null), + expected = Result.failure( + IllegalStateException("Total tokens should not be null"), + ), + ), + TestModel( + value = createDTO(totalNetworks = null), + expected = Result.failure( + IllegalStateException("Total networks should not be null"), + ), + ), + ) + } + + private fun createDTO( + accountId: String = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027", + name: String = "Test Account", + icon: String = "Letter", + iconColor: String = "Azure", + derivationIndex: Int = 0, + totalTokens: Int? = 1, + totalNetworks: Int? = 1, + ): WalletAccountDTO { + return WalletAccountDTO( + id = accountId, + name = name, + derivationIndex = derivationIndex, + icon = icon, + iconColor = iconColor, + tokens = null, + totalTokens = totalTokens, + totalNetworks = totalNetworks, + ) + } + + private fun createDomain(): ArchivedAccount { + return ArchivedAccount( + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(0).getOrNull()!!), + name = "Test Account".toAccountName(), + derivationIndex = 0.toDerivationIndex(), + icon = CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + tokensCount = 1, + networksCount = 1, + ) + } + + data class TestModel( + val value: WalletAccountDTO, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt new file mode 100644 index 0000000000..f046bae96a --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt @@ -0,0 +1,155 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoPortfolioConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk() + private val userTokensResponseFactory: UserTokensResponseFactory = mockk() + private val converter = CryptoPortfolioConverter( + userWallet = userWallet, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + userTokensResponseFactory = userTokensResponseFactory, + ) + + @BeforeEach + fun setupEach() { + clearMocks(responseCryptoCurrenciesFactory, userTokensResponseFactory) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId), + expected = Result.success(createCryptoPortfolio(userWalletId = userWallet.walletId)), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountId = "123"), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = ""), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, icon = "INVALID_ICON"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, iconColor = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, derivationIndex = -1), + expected = Result.failure( + IllegalStateException( + "Unable to create DerivationIndex from value: -1. " + + "Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, tokens = null), + expected = Result.failure( + IllegalStateException("Tokens should not be null"), + ), + ), + ) + } + } + + data class ConvertModel( + val value: WalletAccountDTO, + val expected: Result, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = converter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel( + value = createCryptoPortfolio(userWalletId = userWallet.walletId), + expected = createWalletAccountDTO(userWalletId = userWallet.walletId), + ), + ) + } + } + + data class ConvertBackModel( + val value: Account.CryptoPortfolio, + val expected: WalletAccountDTO, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt new file mode 100644 index 0000000000..c6563f9fc8 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.account.converter.CryptoPortfolioIconConverter.DataModel +import com.tangem.domain.models.account.CryptoPortfolioIcon +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CryptoPortfolioIconConverterTest { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: TestModel) { + // Act + val actual = runCatching { CryptoPortfolioIconConverter.convert(model.value) } + + // Assert + actual + .onSuccess { + val expected = model.expected.getOrNull()!! + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + TestModel( + value = DataModel(icon = "Letter", color = "Azure"), + expected = Result.success( + CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + ), + TestModel( + value = DataModel(icon = "INVALID_ICON", color = "Azure"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + TestModel( + value = DataModel(icon = "Letter", color = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + ) + } + + data class TestModel( + val value: DataModel, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt new file mode 100644 index 0000000000..b6dddb7aa5 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt @@ -0,0 +1,130 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetWalletAccountsResponseConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + private val cryptoPortfolioConverterFactory = mockk() + private val cryptoPortfolioConverter = mockk() + private val converter = GetWalletAccountsResponseConverter( + userWallet = userWallet, + version = 0, + cryptoPortfolioConverterFactory = cryptoPortfolioConverterFactory, + ) + + @BeforeAll + fun setupAll() { + every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + clearMocks(cryptoPortfolioConverter) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `cryptoPortfolioConverter throws exception`() { + // Arrange + val domain = createAccountList(userWallet = userWallet) + val exception = IllegalStateException("Test exception") + + every { cryptoPortfolioConverter.convertBack(any()) } throws exception + + // Act + val actual = runCatching { converter.convert(domain) }.exceptionOrNull()!! + + // Asset + val expected = exception + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual.message).isEqualTo(expected.message) + } + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Arrange + if (model.expected.isSuccess) { + model.value.accounts.forEach { domain -> + val dto = model.expected.getOrNull()!!.accounts.firstOrNull { it.id == domain.accountId.value } + + every { cryptoPortfolioConverter.convertBack(domain as Account.CryptoPortfolio) } returns dto!! + } + } + + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createAccountList( + userWallet = userWallet, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ), + expected = Result.success( + createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.BALANCE, + groupType = UserTokensResponse.GroupType.NETWORK, + ), + ), + ), + ConvertModel( + value = createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + expected = Result.success( + createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MANUAL, + groupType = UserTokensResponse.GroupType.NONE, + ), + ), + ), + ) + } + } + + data class ConvertModel( + val value: AccountList, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt new file mode 100644 index 0000000000..13ad114fe4 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt @@ -0,0 +1,50 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SaveWalletAccountsResponseConverterTest { + + @Test + fun convert() { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns UserWalletId("011") + } + + val accountList = AccountList( + userWallet = userWallet, + accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)), + totalAccounts = 1, + ) + .getOrNull()!! + + // Act + val actual = SaveWalletAccountsResponseConverter.convert(value = accountList) + + // Assert + val expected = SaveWalletAccountsResponse( + accounts = listOf( + WalletAccountDTO( + id = accountList.mainAccount.accountId.value, + name = accountList.mainAccount.accountName.value, + derivationIndex = accountList.mainAccount.derivationIndex.value, + icon = accountList.mainAccount.icon.value.name, + iconColor = accountList.mainAccount.icon.color.name, + ), + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt new file mode 100644 index 0000000000..62ba8b6f30 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt @@ -0,0 +1,85 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensGroupType +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokensGroupTypeConverterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = TokensGroupTypeConverter.convert(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = UserTokensResponse.GroupType.NETWORK, + expected = TokensGroupType.NETWORK, + ), + ConvertModel( + value = UserTokensResponse.GroupType.NONE, + expected = TokensGroupType.NONE, + ), + ConvertModel( + value = UserTokensResponse.GroupType.TOKEN, + expected = TokensGroupType.NONE, + ), + ) + } + } + + data class ConvertModel( + val value: UserTokensResponse.GroupType, + val expected: TokensGroupType, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = TokensGroupTypeConverter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel( + value = TokensGroupType.NETWORK, + expected = UserTokensResponse.GroupType.NETWORK, + ), + ConvertBackModel( + value = TokensGroupType.NONE, + expected = UserTokensResponse.GroupType.NONE, + ), + ) + } + } + + data class ConvertBackModel( + val value: TokensGroupType, + val expected: UserTokensResponse.GroupType, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt new file mode 100644 index 0000000000..e5476f5c49 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt @@ -0,0 +1,81 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensSortType +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokensSortTypeConverterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = TokensSortTypeConverter.convert(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + fun provideTestModels() = listOf( + ConvertModel( + value = UserTokensResponse.SortType.BALANCE, + expected = TokensSortType.BALANCE, + ), + ConvertModel( + value = UserTokensResponse.SortType.MANUAL, + expected = TokensSortType.NONE, + ), + ConvertModel( + value = UserTokensResponse.SortType.MARKETCAP, + expected = TokensSortType.NONE, + ), + ) + } + + data class ConvertModel( + val value: UserTokensResponse.SortType, + val expected: TokensSortType, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = TokensSortTypeConverter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + fun provideTestModels() = listOf( + ConvertBackModel( + value = TokensSortType.BALANCE, + expected = UserTokensResponse.SortType.BALANCE, + ), + ConvertBackModel( + value = TokensSortType.NONE, + expected = UserTokensResponse.SortType.MANUAL, + ), + ) + } + + data class ConvertBackModel( + val value: TokensSortType, + val expected: UserTokensResponse.SortType, + ) +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index 4c2623149b..84b75233ac 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -2,8 +2,9 @@ package com.tangem.data.common.currency import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.models.currency.CryptoCurrency +import javax.inject.Inject -class UserTokensResponseFactory { +class UserTokensResponseFactory @Inject constructor() { fun createUserTokensResponse( currencies: List, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt index a9cf874078..3133206dd3 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt @@ -1,5 +1,8 @@ package com.tangem.domain.models.account +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure import com.tangem.common.extensions.toByteArray import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.extensions.toHexString @@ -18,9 +21,39 @@ data class AccountId private constructor( val userWalletId: UserWalletId, ) { + sealed interface Error { + + val tag: String + get() = this::class.simpleName ?: "AccountId.Error" + + data object Empty : Error { + override fun toString(): String = "$tag: Account ID cannot be blank" + } + + data object InvalidFormat : Error { + override fun toString(): String = "$tag: Account ID must be a 64-character hexadecimal string" + } + } + companion object { private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") } + private val hexRegex = Regex("^[a-fA-F0-9]{64}$") + + /** + * Creates a unique account identifier for a crypto portfolio + * + * @param userWalletId the identifier of the user wallet + * @param value the unique string value representing the account + * + * @return an [Either] containing the [AccountId] on success, or an [Error] on failure + */ + fun forCryptoPortfolio(userWalletId: UserWalletId, value: String): Either = either { + ensure(value.isNotBlank()) { Error.Empty } + ensure(value.matches(hexRegex)) { Error.InvalidFormat } + + AccountId(value = value, userWalletId = userWalletId) + } /** * Creates a unique account identifier for a crypto portfolio From adfbf8d277b7afb54fc7d5eeaf2adc146741a23c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 10:38:49 +0500 Subject: [PATCH 005/132] Updated on 2026-08-14 --- .../modal/TangemModalBottomSheetWithFooter.kt | 24 +++++++++----- .../v2/feeselector/model/FeeSelectorModel.kt | 3 ++ .../ui/FeeSelectorModalBottomSheet.kt | 31 +++++++++++++------ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index 9964ac424d..3826cc503f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -1,6 +1,7 @@ package com.tangem.core.ui.components.bottomsheets.modal import android.content.res.Configuration +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -49,7 +50,7 @@ inline fun TangemModalBottomSheetWi noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current @@ -84,7 +85,7 @@ inline fun DefaultModalBottomSheetW noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { var isVisible by remember { mutableStateOf(value = config.isShown) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) @@ -118,7 +119,7 @@ inline fun PreviewModalBottomSheetW skipPartiallyExpanded: Boolean = true, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable BoxScope.(T) -> Unit, + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { BasicModalBottomSheetWithFooter( config = config, @@ -145,7 +146,7 @@ inline fun BasicModalBottomSheetWit noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, modifier: Modifier = Modifier, ) { val model = config.content as? T ?: return @@ -156,8 +157,13 @@ inline fun BasicModalBottomSheetWit val scrollState = rememberScrollState(initial = initial) val isKeyboardOpen by rememberIsKeyboardVisible() - val buttonHeight = TangemTheme.dimens.spacing80 - val contentBottomPadding = TangemTheme.dimens.spacing80 + val buttonHeight by animateDpAsState( + if (footer != null) { + 80.dp + } else { + 0.dp + }, + ) // Offset calculation for keyboard scroll adjustment: // 1) Button height (footer) // 2) Column content bottom padding @@ -202,7 +208,7 @@ inline fun BasicModalBottomSheetWit Column( modifier = Modifier .verticalScroll(state = scrollState) - .padding(bottom = contentBottomPadding), + .padding(bottom = buttonHeight), ) { content(model) } @@ -218,7 +224,9 @@ inline fun BasicModalBottomSheetWit .height(buttonHeight) .align(Alignment.BottomCenter), ) { - footer(model) + if (footer != null) { + footer(model) + } } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index 2784044d44..07e8e30c81 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -127,6 +127,9 @@ internal class FeeSelectorModel @Inject constructor( ) } uiState.update(FeeItemSelectedTransformer(feeItem)) + if (feeItem !is FeeItem.Custom) { + onDoneClick() + } } override fun onCustomFeeValueChange(index: Int, value: String) { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index c99b647b80..d9aea80f8c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -82,15 +82,19 @@ internal fun FeeSelectorModalBottomSheet( modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), ) }, - footer = { - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - enabled = state.isPrimaryButtonEnabled, - text = stringResourceSafe(R.string.common_done), - onClick = feeSelectorIntents::onDoneClick, - ) + footer = if (state.selectedFeeItem is FeeItem.Custom) { + { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + enabled = state.isPrimaryButtonEnabled, + text = stringResourceSafe(R.string.common_done), + onClick = feeSelectorIntents::onDoneClick, + ) + } + } else { + null }, ) } @@ -426,7 +430,14 @@ private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider< FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), customFeeItem, ), - selectedFeeItem = customFeeItem, + selectedFeeItem = FeeItem.Slow( + fee = Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), feeExtraInfo = FeeExtraInfo( isFeeApproximate = true, isFeeConvertibleToFiat = true, From 92b55cadbf7bd49ef0a31292b6085676c56e41ed Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 12:05:42 +0300 Subject: [PATCH 006/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f430a4dd17..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -11,7 +11,7 @@ tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 1d97ce5203d8b25430a59d4d1e605182054aa713 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 14:35:20 +0500 Subject: [PATCH 007/132] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 11 ++ .../com/tangem/common/routing/AppRoute.kt | 5 + .../main/res/drawable/ic_knight_shield_24.xml | 9 + .../res/drawable/ic_mobile_security_24.xml | 12 ++ .../src/main/res/drawable/ic_protect_24.xml | 9 + .../ui/src/main/res/drawable/ic_tangem_64.xml | 18 ++ .../hotwallet/UpgradeWalletComponent.kt | 14 ++ .../DefaultUpgradeWalletComponent.kt | 39 +++++ .../upgradewallet/UpgradeWalletModel.kt | 41 +++++ .../upgradewallet/di/UpgradeWalletModule.kt | 25 +++ .../upgradewallet/entity/UpgradeWalletUM.kt | 7 + .../upgradewallet/ui/UpgradeWalletContent.kt | 161 ++++++++++++++++++ 12 files changed, 351 insertions(+) create mode 100644 core/ui/src/main/res/drawable/ic_knight_shield_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_mobile_security_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_protect_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_tangem_64.xml create mode 100644 features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.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 615b1c3778..9a80508e7b 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 @@ -17,6 +17,7 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.AddExistingWalletComponent import com.tangem.features.hotwallet.CreateMobileWalletComponent +import com.tangem.features.hotwallet.UpgradeWalletComponent import com.tangem.features.hotwallet.WalletActivationComponent import com.tangem.features.hotwallet.CreateWalletBackupComponent import com.tangem.features.hotwallet.UpdateAccessCodeComponent @@ -103,6 +104,7 @@ internal class ChildFactory @Inject constructor( private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory, private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory, private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory, + private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory, private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory, private val walletActivationComponentFactory: WalletActivationComponent.Factory, private val createWalletBackupComponentFactory: CreateWalletBackupComponent.Factory, @@ -486,6 +488,15 @@ internal class ChildFactory @Inject constructor( componentFactory = createMobileWalletComponentFactory, ) } + is AppRoute.UpgradeWallet -> { + createComponentChild( + context = context, + params = UpgradeWalletComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = upgradeWalletComponentFactory, + ) + } is AppRoute.AddExistingWallet -> { createComponentChild( context = context, 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 d0ffcca403..cfa1070c25 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 @@ -311,6 +311,11 @@ sealed class AppRoute(val path: String) : Route { @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") + @Serializable + data class UpgradeWallet( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/upgrade_wallet/${userWalletId.stringValue}") + @Serializable object AddExistingWallet : AppRoute(path = "/add_existing_wallet") diff --git a/core/ui/src/main/res/drawable/ic_knight_shield_24.xml b/core/ui/src/main/res/drawable/ic_knight_shield_24.xml new file mode 100644 index 0000000000..1a058ff367 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_knight_shield_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_mobile_security_24.xml b/core/ui/src/main/res/drawable/ic_mobile_security_24.xml new file mode 100644 index 0000000000..bbc69392d5 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_mobile_security_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_protect_24.xml b/core/ui/src/main/res/drawable/ic_protect_24.xml new file mode 100644 index 0000000000..5ad478076f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_protect_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_tangem_64.xml b/core/ui/src/main/res/drawable/ic_tangem_64.xml new file mode 100644 index 0000000000..6c39da496c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_tangem_64.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt new file mode 100644 index 0000000000..8a9b7f6c14 --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface UpgradeWalletComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt new file mode 100644 index 0000000000..2f0e91d1de --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.hotwallet.upgradewallet + +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.hotwallet.UpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.ui.UpgradeWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultUpgradeWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: UpgradeWalletComponent.Params, +) : UpgradeWalletComponent, AppComponentContext by context { + + private val model: UpgradeWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + UpgradeWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : UpgradeWalletComponent.Factory { + override fun create( + context: AppComponentContext, + params: UpgradeWalletComponent.Params, + ): DefaultUpgradeWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt new file mode 100644 index 0000000000..bc03e54e43 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt @@ -0,0 +1,41 @@ +package com.tangem.features.hotwallet.upgradewallet + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class UpgradeWalletModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, +) : Model() { + + internal val uiState: StateFlow + field = MutableStateFlow( + UpgradeWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onScanDeviceClick = ::onScanDeviceClick, + ), + ) + + private fun onBuyTangemWalletClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + private fun onScanDeviceClick() { + // TODO [REDACTED_TASK_KEY] + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt new file mode 100644 index 0000000000..3beb13bcab --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.hotwallet.upgradewallet.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.UpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.DefaultUpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.UpgradeWalletModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(SingletonComponent::class) +internal interface UpgradeWalletModule { + + @Binds + fun bindUpgradeWalletComponentFactory(impl: DefaultUpgradeWalletComponent.Factory): UpgradeWalletComponent.Factory + + @Binds + @IntoMap + @ClassKey(UpgradeWalletModel::class) + fun bindUpgradeWalletModel(model: UpgradeWalletModel): Model +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt new file mode 100644 index 0000000000..8f700a27b9 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.hotwallet.upgradewallet.entity + +internal data class UpgradeWalletUM( + val onBackClick: () -> Unit, + val onBuyTangemWalletClick: () -> Unit, + val onScanDeviceClick: () -> Unit, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt new file mode 100644 index 0000000000..2ae6ae5d0c --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt @@ -0,0 +1,161 @@ +package com.tangem.features.hotwallet.upgradewallet.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UpgradeWalletContent(state: UpgradeWalletUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier + .statusBarsPadding(), + startButton = TopAppBarButtonUM.Back(state.onBackClick), + title = TextReference.EMPTY, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + ) { + Icon( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.ic_tangem_64), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 20.dp, + end = 16.dp, + ), + text = stringResourceSafe(R.string.hw_upgrade_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 32.dp), + title = stringResourceSafe(R.string.hw_upgrade_key_migration_title), + description = stringResourceSafe(R.string.hw_upgrade_key_migration_description), + iconRes = R.drawable.ic_mobile_security_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_funds_access_title), + description = stringResourceSafe(R.string.hw_upgrade_funds_access_description), + iconRes = R.drawable.ic_knight_shield_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_general_security_title), + description = stringResourceSafe(R.string.hw_upgrade_general_security_description), + iconRes = R.drawable.ic_protect_24, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.details_buy_wallet), + onClick = state.onBuyTangemWalletClick, + ) + PrimaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.hw_upgrade_scan_device), + onClick = state.onScanDeviceClick, + iconResId = R.drawable.ic_tangem_24, + ) + } + } +} + +@Composable +private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + ) { + Icon( + modifier = Modifier + .padding(horizontal = 12.dp), + painter = painterResource(iconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(top = 4.dp), + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewUpgradeWalletContent() { + TangemThemePreview { + UpgradeWalletContent( + state = UpgradeWalletUM( + onBackClick = {}, + onBuyTangemWalletClick = {}, + onScanDeviceClick = {}, + ), + ) + } +} \ No newline at end of file From 220a43c1b9d38be882dd718a998f32cde379c6f5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 14:36:55 +0500 Subject: [PATCH 008/132] Updated on 2026-08-14 --- .../components/notifications/Notification.kt | 1 + .../notifications/NotificationConfig.kt | 1 + .../hotwallet/accesscode/ui/AccessCode.kt | 2 +- .../hotwallet/common/ui/OptionBlock.kt | 2 +- .../walletbackup/entity/WalletBackupUM.kt | 5 +- .../walletbackup/model/WalletBackupModel.kt | 10 ++-- .../walletbackup/ui/WalletBackupContent.kt | 19 ++++---- .../common/preview/WalletScreenPreviewData.kt | 11 ++++- .../domain/GetMultiWalletWarningsFactory.kt | 46 ++++++++++++++++--- .../wallet/state/model/WalletNotification.kt | 11 +++-- .../components/common/WalletNotifications.kt | 6 +++ 11 files changed, 86 insertions(+), 28 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 06a2bc1d9e..a3e97923bd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -62,6 +62,7 @@ fun Notification( NotificationConfig.IconTint.Unspecified -> null NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention + NotificationConfig.IconTint.Warning -> TangemTheme.colors.icon.warning }, isEnabled: Boolean = true, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt index ca5cdc8e5a..bd4d9dacef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt @@ -65,5 +65,6 @@ data class NotificationConfig( Unspecified, Accent, Attention, + Warning, } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt index 3924d978c7..4596985307 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt @@ -74,7 +74,7 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) { ) { PinTextField( length = state.accessCodeLength, - isPasswordVisual = !state.isConfirmMode, + isPasswordVisual = state.isConfirmMode, value = state.accessCode, pinTextColor = PinTextColor.Primary, onValueChange = state.onAccessCodeChange, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt index 8e90402e09..c41dba03e8 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt @@ -61,7 +61,7 @@ internal fun OptionBlock( color = backgroundColor, shape = TangemTheme.shapes.roundedCornersXMedium, ) - .conditional(onClick != null) { + .conditional(onClick != null && enabled) { onClick?.let { clickableSingle(onClick = it) } ?: Modifier } .padding(16.dp), 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/WalletBackupUM.kt index f3f17cd9d3..3bc262d1a9 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/WalletBackupUM.kt @@ -4,8 +4,9 @@ import com.tangem.core.ui.components.label.entity.LabelUM internal data class WalletBackupUM( val onBackClick: () -> Unit, - val recoveryPhraseStatus: LabelUM?, - val googleDriveStatus: LabelUM?, + val recoveryPhraseOption: LabelUM?, + val googleDriveOption: LabelUM?, + val googleDriveStatus: BackupStatus, val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, val backedUp: Boolean, 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 b398ed047a..9996c7fb7a 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 @@ -20,6 +20,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.common.routing.AppRoute 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 kotlinx.coroutines.flow.* @@ -40,14 +41,15 @@ internal class WalletBackupModel @Inject constructor( field = MutableStateFlow( WalletBackupUM( onBackClick = { router.pop() }, - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), + googleDriveStatus = BackupStatus.ComingSoon, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, backedUp = false, @@ -95,7 +97,7 @@ internal class WalletBackupModel @Inject constructor( } private fun WalletBackupUM.updateBackupStatusesHotWallet(userWallet: UserWallet.Hot): WalletBackupUM = copy( - recoveryPhraseStatus = if (userWallet.backedUp) { + recoveryPhraseOption = if (userWallet.backedUp) { LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, @@ -106,7 +108,7 @@ internal class WalletBackupModel @Inject constructor( style = LabelStyle.WARNING, ) }, - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), 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 f5d25b7280..671c8dd89a 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 @@ -51,7 +51,7 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod title = stringResourceSafe(R.string.hw_backup_seed_title), description = stringResourceSafe(R.string.hw_backup_seed_description), badge = { - state.recoveryPhraseStatus?.let { Label(it) } + state.recoveryPhraseOption?.let { Label(it) } }, onClick = state.onRecoveryPhraseClick, enabled = true, @@ -63,7 +63,7 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod title = stringResourceSafe(R.string.hw_backup_google_drive_title), description = stringResourceSafe(R.string.hw_backup_google_drive_description), badge = { - state.googleDriveStatus?.let { Label(it) } + state.googleDriveOption?.let { Label(it) } }, onClick = state.onGoogleDriveClick, enabled = state.googleDriveStatus != BackupStatus.ComingSoon, @@ -85,42 +85,45 @@ private fun WalletBackupContentPreview(@PreviewParameter(WalletBackupUMProvider: private class WalletBackupUMProvider : CollectionPreviewParameterProvider( collection = listOf( WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), + googleDriveStatus = BackupStatus.ComingSoon, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, backedUp = false, ), WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), + googleDriveStatus = BackupStatus.NoBackup, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, backedUp = false, ), WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, ), + googleDriveStatus = BackupStatus.Done, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index d57ed3079f..9e485dad2e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -3,10 +3,13 @@ package com.tangem.feature.wallet.presentation.common.preview import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo @@ -117,7 +120,13 @@ internal object WalletScreenPreviewData { buttons = persistentListOf(buyButton), warnings = persistentListOf( WalletNotification.Warning.SomeNetworksUnreachable, - WalletNotification.FinishWalletActivation { }, + WalletNotification.FinishWalletActivation( + iconTint = NotificationConfig.IconTint.Attention, + buttonsState = ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = { }, + ), + ), ), bottomSheetConfig = null, tokensListState = textContentTokensState, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 248f54a3e6..a7e173397b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -1,6 +1,9 @@ package com.tangem.feature.wallet.presentation.wallet.domain import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.lce.Lce @@ -19,7 +22,9 @@ import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.domain.wallets.usecase.SeedPhraseNotificationUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents +import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification +import com.tangem.utils.extensions.isPositive import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -54,7 +59,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents) - addFinishWalletActivationNotification(userWallet, clickIntents) + addFinishWalletActivationNotification(userWallet, maybeTokenList, clickIntents) addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) @@ -256,26 +261,55 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( ) } - private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { - if (condition) add(element = element) - } - private fun MutableList.addFinishWalletActivationNotification( userWallet: UserWallet, + maybeTokenList: Lce, clickIntents: WalletClickIntents, ) { if (userWallet !is UserWallet.Hot) return val shouldShowFinishActivation = !userWallet.backedUp + val iconTint = maybeTokenList.fold( + ifLoading = { + if ((it?.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isPositive() == true) { + IconTint.Warning + } else { + IconTint.Attention + } + }, + ifContent = { + if ((it.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isPositive() == true) { + IconTint.Warning + } else { + IconTint.Attention + } + }, + ifError = { IconTint.Attention }, + ) + addIf( element = WalletNotification.FinishWalletActivation( - onFinishClick = clickIntents::onFinishWalletActivationClick, + iconTint = iconTint, + buttonsState = when (iconTint) { + IconTint.Warning -> ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = clickIntents::onFinishWalletActivationClick, + ) + else -> ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = clickIntents::onFinishWalletActivationClick, + ) + }, ), condition = shouldShowFinishActivation, ) } + private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { + if (condition) add(element = element) + } + private companion object { const val MAX_REMAINING_SIGNATURES_COUNT = 10 } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index 3d23c93ae5..dd6ae1ec2b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -3,6 +3,8 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference @@ -257,16 +259,15 @@ sealed class WalletNotification(val config: NotificationConfig) { ) data class FinishWalletActivation( - val onFinishClick: () -> Unit, + val iconTint: IconTint, + val buttonsState: ButtonsState, ) : WalletNotification( config = NotificationConfig( title = resourceReference(R.string.hw_activation_need_title), subtitle = resourceReference(R.string.hw_activation_need_description), iconResId = R.drawable.img_knight_shield_32, - buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.hw_activation_need_finish), - onClick = onFinishClick, - ), + iconTint = iconTint, + buttonsState = buttonsState, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 211ce57e2d..172ab84fbe 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -34,6 +34,12 @@ internal fun LazyListScope.notifications(configs: ImmutableList { + Notification( + config = it.config, + modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), + ) + } else -> { Notification( config = it.config, From 072290b144183fdd2151e9fc460c6360f7b8bf0c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 Aug 2025 11:05:10 +0500 Subject: [PATCH 009/132] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 ++ features/yield-lending/api/.gitignore | 1 + features/yield-lending/api/build.gradle.kts | 24 +++++++++++ .../api/YieldLendingFeatureToggles.kt | 6 +++ features/yield-lending/impl/.gitignore | 1 + features/yield-lending/impl/build.gradle.kts | 41 +++++++++++++++++++ .../impl/DefaultYieldLendingFeatureToggles.kt | 11 +++++ .../impl/di/YieldLendingFeatureModule.kt | 21 ++++++++++ settings.gradle.kts | 3 ++ 9 files changed, 112 insertions(+) create mode 100644 features/yield-lending/api/.gitignore create mode 100644 features/yield-lending/api/build.gradle.kts create mode 100644 features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt create mode 100644 features/yield-lending/impl/.gitignore create mode 100644 features/yield-lending/impl/build.gradle.kts create mode 100644 features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt create mode 100644 features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt 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 bd1f7e6e67..318f39ce65 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 @@ -62,5 +62,9 @@ { "name": "NEW_TOKEN_RECEIVE_ENABLED", "version": "5.28.0" + }, + { + "name": "YIELD_LENDING_FEATURE_ENABLED", + "version": "undefined" } ] diff --git a/features/yield-lending/api/.gitignore b/features/yield-lending/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/yield-lending/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/yield-lending/api/build.gradle.kts b/features/yield-lending/api/build.gradle.kts new file mode 100644 index 0000000000..da7d806bb3 --- /dev/null +++ b/features/yield-lending/api/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.yieldlending.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) + + /** Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt b/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt new file mode 100644 index 0000000000..3d1c8a10bd --- /dev/null +++ b/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.yieldlending.api + +interface YieldLendingFeatureToggles { + + val isYieldLendingFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/yield-lending/impl/.gitignore b/features/yield-lending/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/yield-lending/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/yield-lending/impl/build.gradle.kts b/features/yield-lending/impl/build.gradle.kts new file mode 100644 index 0000000000..40ebde87d3 --- /dev/null +++ b/features/yield-lending/impl/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.yieldlending.impl" +} + +dependencies { + + /** Feature */ + implementation(projects.features.yieldLending.api) + + /** Core */ + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.runtime) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.androidx.activity.compose) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt new file mode 100644 index 0000000000..4e780b4e96 --- /dev/null +++ b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.yieldlending.impl + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yieldlending.api.YieldLendingFeatureToggles + +internal class DefaultYieldLendingFeatureToggles( + private val featureToggles: FeatureTogglesManager, +) : YieldLendingFeatureToggles { + override val isYieldLendingFeatureEnabled: Boolean + get() = featureToggles.isFeatureEnabled("YIELD_LENDING_FEATURE_ENABLED") +} \ No newline at end of file diff --git a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt new file mode 100644 index 0000000000..d666cb36a1 --- /dev/null +++ b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.yieldlending.impl.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yieldlending.api.YieldLendingFeatureToggles +import com.tangem.features.yieldlending.impl.DefaultYieldLendingFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal object YieldLendingFeatureModule { + + @Singleton + @Provides + fun provideYieldFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldLendingFeatureToggles { + return DefaultYieldLendingFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index d785e00c38..7c7251cb46 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -281,6 +281,9 @@ include(":features:account:impl") include(":features:token-recieve:api") include(":features:token-recieve:impl") + +include(":features:yield-lending:api") +include(":features:yield-lending:impl") // endregion Feature modules // region Domain modules From 3a96b203e0171f2375ddc0b31c1c74f30381055f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 19:14:43 +0700 Subject: [PATCH 010/132] Updated on 2026-08-14 --- .../tangem/common/ui/account/AccountRow.kt | 5 ++ .../converter/UserWalletItemUMConverter.kt | 17 +--- features/markets/impl/build.gradle.kts | 1 + .../model/AddToPortfolioBSContentUMFactory.kt | 7 +- .../impl/model/MarketsPortfolioModel.kt | 59 ++++++------ .../impl/model/MyPortfolioUMFactory.kt | 6 +- .../wallet-settings/impl/build.gradle.kts | 2 + .../preview/PreviewWalletSettingsComponent.kt | 15 +++- .../entity/WalletSettingsItemUM.kt | 4 +- .../model/WalletSettingsModel.kt | 36 +++----- .../walletsettings/ui/WalletSettingsScreen.kt | 50 +++++++---- .../walletsettings/utils/ItemsBuilder.kt | 18 +--- .../utils/WalletCardItemDelegate.kt | 55 ++++++++++++ features/wallet/api/build.gradle.kts | 3 + .../wallet/utils/UserWalletImageFetcher.kt | 20 +++++ .../feature/wallet/di/WalletFeatureModule.kt | 7 ++ .../utils/DefaultUserWalletImageFetcher.kt | 89 +++++++++++++++++++ .../wallet/utils/DefaultUserWalletsFetcher.kt | 27 +----- 18 files changed, 285 insertions(+), 136 deletions(-) create mode 100644 features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt create mode 100644 features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt index c48fa40072..d02fe62c15 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.R import com.tangem.core.ui.extensions.TextReference @@ -73,6 +74,8 @@ private fun Title(title: TextReference) { text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -82,6 +85,8 @@ private fun Subtitle(subtitle: TextReference) { color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption2, text = subtitle.resolveReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt index 41f2c3611e..41b0727d2d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt @@ -2,8 +2,8 @@ package com.tangem.common.ui.userwallet.converter import com.tangem.common.ui.R import com.tangem.common.ui.userwallet.state.UserWalletItemUM -import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -12,7 +12,6 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.card.common.util.getCardsCount -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet @@ -37,10 +36,10 @@ class UserWalletItemUMConverter( private val isBalanceHidden: Boolean = false, private val authMode: Boolean = false, private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None, - private val artwork: ArtworkModel? = null, + artwork: UserWalletItemUM.ImageState? = null, ) : Converter { - private val artworkUMConverter = ArtworkUMConverter() + private val artwork = artwork ?: UserWalletItemUM.ImageState.Loading override fun convert(value: UserWallet): UserWalletItemUM { return with(value) { @@ -52,7 +51,7 @@ class UserWalletItemUMConverter( isEnabled = isEnabled(userWallet = this), endIcon = endIcon, onClick = { onClick(value.walletId) }, - imageState = getImageState(userWallet = value), + imageState = artwork, label = getLabelOrNull(userWallet = this), ) } @@ -73,14 +72,6 @@ class UserWalletItemUMConverter( } } - private fun getImageState(userWallet: UserWallet): UserWalletItemUM.ImageState { - return when { - userWallet is UserWallet.Hot -> UserWalletItemUM.ImageState.MobileWallet - artwork != null -> UserWalletItemUM.ImageState.Image(artworkUMConverter.convert(artwork)) - else -> UserWalletItemUM.ImageState.Loading - } - } - private fun getInfo(userWallet: UserWallet): UserWalletItemUM.Information.Loaded { val text = when (userWallet) { is UserWallet.Cold -> { diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 1b9562b290..61d498c799 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(projects.features.onramp.api) api(projects.features.sendV2.api) api(projects.features.tokenRecieve.api) + api(projects.features.wallet.api) /* Data */ implementation(projects.data.common) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt index feef86486d..49b7fead68 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -7,7 +7,6 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -56,7 +55,7 @@ internal class AddToPortfolioBSContentUMFactory( portfolioUIData: PortfolioUIData, selectedWallet: UserWallet?, alreadyAddedNetworks: Set?, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { return (currentState ?: TangemBottomSheetConfig.Empty).copy( isShown = portfolioUIData.portfolioBSVisibilityModel.addToPortfolioBSVisibility, @@ -110,7 +109,7 @@ internal class AddToPortfolioBSContentUMFactory( } private fun UserWallet.toSelectedUserWalletItemUM( - artwork: ArtworkModel? = null, + artwork: UserWalletItemUM.ImageState? = null, portfolioData: PortfolioData, balance: TotalFiatBalance?, ): UserWalletItemUM { @@ -128,7 +127,7 @@ internal class AddToPortfolioBSContentUMFactory( isShow: Boolean, portfolioData: PortfolioData, selectedWalletId: UserWalletId, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { return TangemBottomSheetConfig( isShown = isShow, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index 60673724c0..5b6bd84ca2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.markets.portfolio.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -21,7 +22,6 @@ import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.markets.SaveMarketTokensUseCase import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -31,7 +31,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.transaction.usecase.GetEnsNameUseCase -import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.markets.impl.R @@ -42,13 +41,14 @@ import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject @@ -66,21 +66,17 @@ internal class MarketsPortfolioModel @Inject constructor( private val portfolioDataLoader: PortfolioDataLoader, private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, private val saveMarketTokensUseCase: SaveMarketTokensUseCase, - private val getCardImageUseCase: GetCardImageUseCase, private val addToPortfolioManager: AddToPortfolioManager, private val analyticsEventHandler: AnalyticsEventHandler, private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, private val getEnsNameUseCase: GetEnsNameUseCase, + private val userWalletImageFetcher: UserWalletImageFetcher, ) : Model() { val state: StateFlow get() = _state private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) - private val loadedArtworks: HashMap = hashMapOf() - private val artworksState: MutableStateFlow> = MutableStateFlow(hashMapOf()) - private val loadArtworksMutex = Mutex() - private val params = paramsContainer.require() private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( token = params.token, @@ -196,38 +192,33 @@ internal class MarketsPortfolioModel @Inject constructor( private fun subscribeOnStateUpdates() { combine( - flow = loadPortfolioData(params.token.id), + flow = loadPortfolioDataWithArtworks(params.token.id), flow2 = getPortfolioUIDataFlow(), - flow3 = artworksState, - transform = factory::create, + transform = { pair, portfolioUIData -> + val (portfolioData, artworks) = pair + factory.create(portfolioData, portfolioUIData, artworks) + }, ) .onEach { _state.value = it } .launchIn(modelScope) } - private fun loadPortfolioData(currencyRawId: CryptoCurrency.RawID): Flow { - portfolioDataLoader.load(currencyRawId).onEach { - loadArtworks(it.walletsWithCurrencies.keys.toList()) - }.also { return it } - } + private fun loadPortfolioDataWithArtworks( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + val wallets = Channel>() + val portfolioFlow = portfolioDataLoader + .load(currencyRawId) + .onEach { wallets.trySend(it.walletsWithCurrencies.keys) } - private fun loadArtworks(wallets: List) { - modelScope.launch { - loadArtworksMutex.withLock { - wallets.filterIsInstance().forEach { wallet -> - if (!loadedArtworks.containsKey(wallet.walletId)) { - val artwork = getCardImageUseCase( - cardId = wallet.cardId, - manufacturerName = wallet.scanResponse.card.manufacturer.name, - firmwareVersion = wallet.scanResponse.card.firmwareVersion.toSdkFirmwareVersion(), - cardPublicKey = wallet.scanResponse.card.cardPublicKey, - ) - loadedArtworks[wallet.walletId] = artwork - artworksState.emit(loadedArtworks) - } - } - } - } + val artworksFlow = wallets.receiveAsFlow() + .distinctUntilChanged() + .flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) } + + return combine( + flow = portfolioFlow, + flow2 = artworksFlow, + ) { portfolioData, artworks -> portfolioData to artworks } } private fun getPortfolioUIDataFlow(): Flow { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt index 5c5bb59b96..44fd810a9a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -1,8 +1,8 @@ package com.tangem.features.markets.portfolio.impl.model +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -36,7 +36,7 @@ internal class MyPortfolioUMFactory( fun create( portfolioData: PortfolioData, portfolioUIData: PortfolioUIData, - artworks: HashMap, + artworks: Map, ): MyPortfolioUM { val addToPortfolioData = portfolioUIData.addToPortfolioData @@ -89,7 +89,7 @@ internal class MyPortfolioUMFactory( private fun createAddToPortfolioBSConfig( portfolioData: PortfolioData, portfolioUIData: PortfolioUIData, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { val selectedWallet = portfolioData.walletsWithCurrencies.keys .firstOrNull { it.walletId == portfolioUIData.selectedWalletId } diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index 3f34abd5a1..4ab6bc0a40 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(projects.features.onboardingV2.api) implementation(projects.features.pushNotifications.api) implementation(projects.features.hotWallet.api) + implementation(projects.features.wallet.api) /* Project - Core */ implementation(projects.core.decompose) @@ -69,4 +70,5 @@ dependencies { /** Tangem libraries */ implementation(tangemDeps.hot.core) + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 69bf4cfb75..e716a35761 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -3,6 +3,7 @@ package com.tangem.feature.walletsettings.component.preview import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState import com.tangem.core.analytics.DummyAnalyticsEventHandler import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.ui.components.block.model.BlockUM @@ -13,6 +14,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.walletsettings.component.WalletSettingsComponent import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM +import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.ui.WalletSettingsScreen @@ -34,14 +36,11 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { wallets = null, backedUp = false, ), - userWalletName = "My Wallet", isReferralAvailable = true, isLinkMoreCardsAvailable = true, - isRenameWalletAvailable = false, isNFTFeatureEnabled = true, isNFTEnabled = true, onCheckedNFTChange = {}, - renameWallet = {}, forgetWallet = {}, onLinkMoreCardsClick = {}, onReferralClick = {}, @@ -56,6 +55,7 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { onUpgradeWalletClick = {}, onDismissUpgradeWalletClick = {}, accountsUM = previewAccounts(), + cardItem = previewCardBlock(), ), requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = {}, @@ -90,6 +90,15 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { ).let(::add) } + private fun previewCardBlock() = WalletSettingsItemUM.CardBlock( + id = "wallet_name", + title = resourceReference(id = R.string.user_wallet_list_rename_popup_placeholder), + text = stringReference("Wallet Name"), + isEnabled = true, + onClick = { }, + imageState = ImageState.MobileWallet, + ) + @Composable override fun Content(modifier: Modifier) { WalletSettingsScreen( diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 85a6bcb784..d2cb70fe36 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -2,6 +2,7 @@ package com.tangem.feature.walletsettings.entity import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -24,11 +25,12 @@ internal sealed class WalletSettingsItemUM { val onCheckedChange: (Boolean) -> Unit, ) : WalletSettingsItemUM() - data class WithText( + data class CardBlock( override val id: String, val title: TextReference, val text: TextReference, val isEnabled: Boolean, + val imageState: ImageState, val onClick: () -> Unit, ) : WalletSettingsItemUM() diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 1a5e3ee067..66ddf7f18f 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.model import android.os.Build +import arrow.core.Either import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate @@ -44,6 +45,7 @@ import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.utils.AccountItemsDelegate import com.tangem.feature.walletsettings.utils.ItemsBuilder +import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList @@ -66,7 +68,7 @@ internal class WalletSettingsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsContextProxy: AnalyticsContextProxy, - private val getShouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, + walletCardItemDelegateFactory: WalletCardItemDelegate.Factory, private val isDemoCardUseCase: IsDemoCardUseCase, getWalletNFTEnabledUseCase: GetWalletNFTEnabledUseCase, private val enableWalletNFTUseCase: EnableWalletNFTUseCase, @@ -85,6 +87,7 @@ internal class WalletSettingsModel @Inject constructor( val params: WalletSettingsComponent.Params = paramsContainer.require() val dialogNavigation = SlotNavigation() val bottomSheetNavigation: SlotNavigation = SlotNavigation() + private val walletCardItemDelegate = walletCardItemDelegateFactory.create(dialogNavigation) val state: MutableStateFlow = MutableStateFlow( value = WalletSettingsUM( @@ -117,14 +120,12 @@ internal class WalletSettingsModel @Inject constructor( } init { - combine( - getWalletUseCase.invokeFlow(params.userWalletId).distinctUntilChanged(), + fun combineUI(wallet: UserWallet) = combine( getWalletNFTEnabledUseCase.invoke(params.userWalletId), getWalletNotificationsEnabledUseCase(params.userWalletId), isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), - ) { maybeWallet, nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled -> - val wallet = maybeWallet.getOrNull() ?: return@combine - val isRenameWalletAvailable = getShouldSaveUserWalletsSyncUseCase() + walletCardItemDelegate.cardItemFlow(wallet), + ) { nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled, cardItem -> val isWalletBackedUp = when (wallet) { is UserWallet.Hot -> wallet.backedUp is UserWallet.Cold -> true @@ -135,8 +136,7 @@ internal class WalletSettingsModel @Inject constructor( value.copy( items = buildItems( userWallet = wallet, - dialogNavigation = dialogNavigation, - isRenameWalletAvailable = isRenameWalletAvailable, + cardItem = cardItem, isNFTEnabled = nftEnabled, isNotificationsEnabled = notificationsEnabled, isNotificationsFeatureEnabled = isNeedShowNotifications, @@ -147,6 +147,10 @@ internal class WalletSettingsModel @Inject constructor( ) } } + getWalletUseCase.invokeFlow(params.userWalletId) + .distinctUntilChanged() + .filterIsInstance>() + .flatMapLatest { combineUI(it.value) } .launchIn(modelScope) } @@ -162,8 +166,7 @@ internal class WalletSettingsModel @Inject constructor( private fun buildItems( userWallet: UserWallet, - dialogNavigation: SlotNavigation, - isRenameWalletAvailable: Boolean, + cardItem: WalletSettingsItemUM.CardBlock, isNFTEnabled: Boolean, isNotificationsFeatureEnabled: Boolean, isNotificationsEnabled: Boolean, @@ -176,7 +179,7 @@ internal class WalletSettingsModel @Inject constructor( } return itemsBuilder.buildItems( userWallet = userWallet, - userWalletName = userWallet.name, + cardItem = cardItem, isReferralAvailable = when (userWallet) { is UserWallet.Cold -> userWallet.cardTypesResolver.isTangemWallet() is UserWallet.Hot -> false @@ -186,8 +189,6 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Hot -> false }, isManageTokensAvailable = isMultiCurrency, - isRenameWalletAvailable = isRenameWalletAvailable, - renameWallet = { openRenameWalletDialog(userWallet, dialogNavigation) }, isNFTFeatureEnabled = isMultiCurrency, isNFTEnabled = isNFTEnabled, onCheckedNFTChange = ::onCheckedNFTChange, @@ -226,15 +227,6 @@ internal class WalletSettingsModel @Inject constructor( ) } - private fun openRenameWalletDialog(userWallet: UserWallet, dialogNavigation: SlotNavigation) { - val config = DialogConfig.RenameWallet( - userWalletId = userWallet.walletId, - currentName = userWallet.name, - ) - - dialogNavigation.activate(config) - } - private fun forgetWallet() = modelScope.launch { val hasUserWallets = deleteWalletUseCase(params.userWalletId).getOrElse { Timber.e("Unable to delete wallet: $it") diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index b18c80f1f0..c0d5e0cd86 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.common.ui.account.AccountRow +import com.tangem.common.ui.userwallet.CardImage import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.TangemSwitch @@ -30,10 +31,13 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.BlockItem +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -115,7 +119,7 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) - is WalletSettingsItemUM.WithText -> TextBlock( + is WalletSettingsItemUM.CardBlock -> CardBlock( modifier = itemModifier, model = item, ) @@ -180,30 +184,40 @@ private fun ItemsBlock(model: WalletSettingsItemUM.WithItems, modifier: Modifier } @Composable -private fun TextBlock(model: WalletSettingsItemUM.WithText, modifier: Modifier = Modifier) { +private fun CardBlock(model: WalletSettingsItemUM.CardBlock, modifier: Modifier = Modifier) { BlockCard( modifier = modifier.fillMaxWidth(), enabled = model.isEnabled, onClick = model.onClick, ) { - Column( + Row( modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = model.title.resolveReference(), - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.subtitle2, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = model.text.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.body1, - overflow = TextOverflow.Ellipsis, + CardImage(model.imageState) + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.title.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = model.text.resolveReference(), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + SecondarySmallButton( + config = SmallButtonConfig( + enabled = model.isEnabled, + text = resourceReference(R.string.common_rename), + onClick = model.onClick, + ), ) } } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index d26dc7fd98..6d2bf36754 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -9,7 +9,6 @@ import com.tangem.core.ui.components.block.model.BlockUM 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.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM @@ -30,12 +29,11 @@ internal class ItemsBuilder @Inject constructor( @Suppress("LongParameterList") fun buildItems( userWallet: UserWallet, - userWalletName: String, + cardItem: WalletSettingsItemUM.CardBlock, accountsUM: List, isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, isManageTokensAvailable: Boolean, - isRenameWalletAvailable: Boolean, isNFTFeatureEnabled: Boolean, isNFTEnabled: Boolean, onCheckedNFTChange: (Boolean) -> Unit, @@ -45,7 +43,6 @@ internal class ItemsBuilder @Inject constructor( onCheckedNotificationsChanged: (Boolean) -> Unit, onNotificationsDescriptionClick: () -> Unit, forgetWallet: () -> Unit, - renameWallet: () -> Unit, onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, onAccessCodeClick: () -> Unit, @@ -53,7 +50,7 @@ internal class ItemsBuilder @Inject constructor( onUpgradeWalletClick: () -> Unit, onDismissUpgradeWalletClick: () -> Unit, ): PersistentList = persistentListOf() - .add(buildNameItem(userWalletName, isRenameWalletAvailable, renameWallet)) + .add(cardItem) .addAll( buildUpgradeWalletItem( userWallet = userWallet, @@ -62,8 +59,8 @@ internal class ItemsBuilder @Inject constructor( onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, ), ) - .addAll(accountsUM) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) + .addAll(accountsUM) .add( buildCardItem( userWallet = userWallet, @@ -121,15 +118,6 @@ internal class ItemsBuilder @Inject constructor( } } - private fun buildNameItem(walletName: String, isRenameWalletAvailable: Boolean, renameWallet: () -> Unit) = - WalletSettingsItemUM.WithText( - id = "wallet_name", - title = resourceReference(id = R.string.settings_wallet_name_title), - text = stringReference(walletName), - isEnabled = isRenameWalletAvailable, - onClick = renameWallet, - ) - private fun buildNFTItem(isNFTEnabled: Boolean, onCheckedNFTChange: (Boolean) -> Unit) = WalletSettingsItemUM.WithSwitch( id = "nft", diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt new file mode 100644 index 0000000000..322eaaac62 --- /dev/null +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt @@ -0,0 +1,55 @@ +package com.tangem.feature.walletsettings.utils + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase +import com.tangem.feature.walletsettings.entity.DialogConfig +import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM +import com.tangem.feature.walletsettings.impl.R +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.operations.attestation.ArtworkSize +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow + +internal class WalletCardItemDelegate @AssistedInject constructor( + private val getShouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, + private val walletImageFetcher: UserWalletImageFetcher, + @Assisted private val dialogNavigation: SlotNavigation, +) { + + fun cardItemFlow(wallet: UserWallet): Flow = combine( + flow = walletImageFetcher.walletImage(wallet, ArtworkSize.SMALL), + flow2 = flow { emit(getShouldSaveUserWalletsSyncUseCase()) }, + transform = { imageState, isRenameAvailable -> + val walletName = wallet.name + WalletSettingsItemUM.CardBlock( + id = "wallet_name", + title = resourceReference(id = R.string.user_wallet_list_rename_popup_placeholder), + text = stringReference(walletName), + isEnabled = isRenameAvailable, + onClick = { openRenameWalletDialog(wallet) }, + imageState = imageState, + ) + }, + ) + + private fun openRenameWalletDialog(userWallet: UserWallet) { + val config = DialogConfig.RenameWallet( + userWalletId = userWallet.walletId, + currentName = userWallet.name, + ) + dialogNavigation.activate(config) + } + + @AssistedFactory + interface Factory { + fun create(dialogNavigation: SlotNavigation): WalletCardItemDelegate + } +} \ No newline at end of file diff --git a/features/wallet/api/build.gradle.kts b/features/wallet/api/build.gradle.kts index 3b1f5eeea2..4c23e065f7 100644 --- a/features/wallet/api/build.gradle.kts +++ b/features/wallet/api/build.gradle.kts @@ -15,6 +15,9 @@ dependencies { /** Project - Domain */ implementation(projects.domain.models) + /** Tangem libraries */ + implementation(tangemDeps.card.core) + /** Core */ implementation(projects.core.ui) implementation(projects.core.decompose) diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt new file mode 100644 index 0000000000..42738f4e11 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt @@ -0,0 +1,20 @@ +package com.tangem.features.wallet.utils + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.operations.attestation.ArtworkSize +import kotlinx.coroutines.flow.Flow + +interface UserWalletImageFetcher { + + fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow + fun walletImage(cardDTO: CardDTO, size: ArtworkSize): Flow + fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow + + fun walletsImage( + wallets: Collection, + size: ArtworkSize, + ): Flow> +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt index 74a02c34b7..1a925f467e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt @@ -4,8 +4,10 @@ import com.tangem.core.decompose.model.Model import com.tangem.feature.wallet.DefaultWalletEntryComponent import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel import com.tangem.feature.wallet.child.wallet.model.WalletModel +import com.tangem.feature.wallet.utils.DefaultUserWalletImageFetcher import com.tangem.feature.wallet.utils.DefaultUserWalletsFetcher import com.tangem.features.wallet.WalletEntryComponent +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.features.wallet.utils.UserWalletsFetcher import dagger.Binds import dagger.Module @@ -13,6 +15,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -24,6 +27,10 @@ internal interface WalletFeatureModule { @Binds fun bindUserWalletsFetcher(impl: DefaultUserWalletsFetcher.Factory): UserWalletsFetcher.Factory + @Binds + @Singleton + fun bindUserWalletImageFetcher(impl: DefaultUserWalletImageFetcher): UserWalletImageFetcher + @Binds @IntoMap @ClassKey(WalletModel::class) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt new file mode 100644 index 0000000000..f6f11319dd --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt @@ -0,0 +1,89 @@ +package com.tangem.feature.wallet.utils + +import arrow.core.Either +import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.operations.attestation.ArtworkSize +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +class DefaultUserWalletImageFetcher @Inject constructor( + private val getCardImageUseCase: GetCardImageUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val artworkUMConverter: ArtworkUMConverter, +) : UserWalletImageFetcher { + + private val smallCache = MutableStateFlow(mapOf()) + private val largeCache = MutableStateFlow(mapOf()) + + override fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow = when (wallet) { + is UserWallet.Cold -> walletImage(wallet.scanResponse.card, size) + is UserWallet.Hot -> flowOf(UserWalletItemUM.ImageState.MobileWallet) + } + + override fun walletsImage( + wallets: Collection, + size: ArtworkSize, + ): Flow> = wallets + .map { userWallet -> walletImage(userWallet, size).map { imageState -> userWallet.walletId to imageState } } + .merge() + .runningFold(mapOf()) { map, newState -> map.plus(newState) } + .filter { it.size >= wallets.size } // prevent spam, waiting full map + .distinctUntilChanged() + + override fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow = flow { + val imagesFlow = getUserWalletUseCase.invokeFlow(walletId) + // emit Loading and wait wallet + .onEach { if (it.isLeft()) emit(UserWalletItemUM.ImageState.Loading) } + .filterIsInstance>() + .map { it.value } + .distinctUntilChanged() + .flatMapLatest { wallet -> walletImage(wallet, size) } + emitAll(imagesFlow) + }.distinctUntilChanged() + + override fun walletImage(cardDTO: CardDTO, size: ArtworkSize): Flow = + internalGetCardImage( + cardInfo = cardDTO, + size = size, + ).distinctUntilChanged() + + private fun internalGetCardImage(cardInfo: CardDTO, size: ArtworkSize): Flow = flow { + emit(cacheOrLoading(cardInfo.cardId, size)) + + val artwork = getCardImageUseCase.invoke( + cardId = cardInfo.cardId, + cardPublicKey = cardInfo.cardPublicKey, + size = size, + manufacturerName = cardInfo.manufacturer.name, + firmwareVersion = cardInfo.firmwareVersion.toSdkFirmwareVersion(), + ) + .let { artworkUMConverter.convert(it) } + .also { save(cardInfo.cardId, size, it) } + emit(UserWalletItemUM.ImageState.Image(artwork)) + } + + private fun cacheOrLoading(cardId: String, size: ArtworkSize): UserWalletItemUM.ImageState { + val artwork = when (size) { + ArtworkSize.LARGE -> largeCache.value[cardId] + ArtworkSize.SMALL -> smallCache.value[cardId] + } + return artwork + ?.let { UserWalletItemUM.ImageState.Image(artwork) } + ?: UserWalletItemUM.ImageState.Loading + } + + private fun save(cardId: String, size: ArtworkSize, artwork: ArtworkUM) { + when (size) { + ArtworkSize.LARGE -> largeCache.update { it.plus(cardId to artwork) } + ArtworkSize.SMALL -> smallCache.update { it.plus(cardId to artwork) } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt index 7a3b9accba..6a310e5c31 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt @@ -14,16 +14,15 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.lce import com.tangem.domain.core.utils.toLce -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.wallet.impl.R +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -45,11 +44,10 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( @Assisted private val messageSender: UiMessageSender, @Assisted("onlyMultiCurrency") private val onlyMultiCurrency: Boolean, @Assisted("authMode") private val authMode: Boolean, - private val getCardImageUseCase: GetCardImageUseCase, + private val userWalletImageFetcher: UserWalletImageFetcher, dispatchers: CoroutineDispatcherProvider, ) : UserWalletsFetcher { - private var loadedArtworks: HashMap = hashMapOf() private val walletsFlow = if (onlyMultiCurrency) getWalletsUseCase().map { it.filter { it.isMultiCurrency } } else getWalletsUseCase() @@ -67,7 +65,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( flow = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getBalanceHidingSettingsUseCase().distinctUntilChanged(), flow3 = getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)).distinctUntilChanged(), - flow4 = loadArtworks(wallets), + flow4 = userWalletImageFetcher.walletsImage(wallets, ArtworkSize.SMALL), ) { maybeAppCurrency, balanceHidingSettings, maybeBalances, artworks -> createUiModels( wallets = wallets, @@ -90,29 +88,12 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( } .flowOn(dispatchers.default) - private fun loadArtworks(wallets: List): Flow> { - return flow { - emit(hashMapOf()) // emits right away so the transform doesn't wait for the images' loading to finish - wallets.filterIsInstance().forEach { wallet -> - val artwork = getCardImageUseCase( - cardId = wallet.cardId, - manufacturerName = wallet.scanResponse.card.manufacturer.name, - firmwareVersion = wallet.scanResponse.card.firmwareVersion.toSdkFirmwareVersion(), - cardPublicKey = wallet.scanResponse.card.cardPublicKey, - size = ArtworkSize.SMALL, - ) - loadedArtworks[wallet.walletId] = artwork - emit(loadedArtworks) - } - } - } - private fun createUiModels( wallets: List, maybeAppCurrency: Either, maybeBalances: Lce>, balanceHidingSettings: BalanceHidingSettings, - artworks: HashMap, + artworks: Map, ): Lce> = lce { val balances = withError( transform = { Error.UnableToGetBalances }, From 710002e992ce3e3026e83315d8bbaa5035193a8f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 Aug 2025 19:31:47 +0400 Subject: [PATCH 011/132] Updated on 2026-08-14 --- data/account/build.gradle.kts | 10 +- .../AccountConverterFactoryContainer.kt | 20 ++++ .../store/AccountsResponseStoreFactory.kt | 66 ++++++++++++++ .../store/AccountsResponseStoreFactoryTest.kt | 91 +++++++++++++++++++ .../createedit/AccountCreateEditModel.kt | 18 +++- 5 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 287f2b190f..90800c1228 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -25,18 +25,24 @@ dependencies { api(projects.domain.models) // endregion - // Project - Data + // region Project - Data implementation(projects.data.common) // endregion // region DI - implementation(deps.hilt.core) + implementation(deps.hilt.android) kapt(deps.hilt.kapt) // endregion + // region AndroidX libraries + implementation(deps.androidx.datastore) + // endregion + // region Other Dependencies implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) + implementation(deps.moshi) + implementation(deps.moshi.kotlin) implementation(deps.timber) // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt new file mode 100644 index 0000000000..28cfd01b9e --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt @@ -0,0 +1,20 @@ +package com.tangem.data.account.converter + +import javax.inject.Inject + +/** + * Container for converter factories related to accounts. + * + * @property accountsListCF factory for creating an account list converter + * @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter + * @property cryptoPortfolioCF factory for creating a crypto portfolio converter + * + * @constructor Creates an instance of the container with injected factories. + * +[REDACTED_AUTHOR] + */ +internal class AccountConverterFactoryContainer @Inject constructor( + val accountsListCF: AccountListConverter.Factory, + val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory, + val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, +) \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt new file mode 100644 index 0000000000..a8ffea4d37 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.store + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +typealias AccountsResponseStore = DataStore + +/** + * Factory class for creating and managing instances of [AccountsResponseStore]. + * This class is responsible for creating a [DataStore] for each unique [UserWalletId]. + * + * @property context application context used to access the file system + * @property moshi moshi instance for JSON serialization and deserialization + * @property dispatchers coroutine dispatcher provider + * +[REDACTED_AUTHOR] + */ +internal class AccountsResponseStoreFactory @Inject constructor( + @ApplicationContext private val context: Context, + @NetworkMoshi private val moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, +) { + + @OptIn(ExperimentalStdlibApi::class) + private val adapter by lazy { moshi.adapter() } + + private val createdDataStores = ConcurrentHashMap() + + /** + * Creates or retrieves an [AccountsResponseStore] for the given [UserWalletId]. + * + * @param userWalletId the unique identifier of the user's wallet + */ + fun create(userWalletId: UserWalletId): AccountsResponseStore { + return createdDataStores.computeIfAbsent(userWalletId) { + DataStoreFactory.create( + serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter), + produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + } + + @VisibleForTesting + fun getAllStores(): Map = createdDataStores.toMap() + + @VisibleForTesting + fun clearStores() { + createdDataStores.clear() + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt new file mode 100644 index 0000000000..6c39f7181c --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt @@ -0,0 +1,91 @@ +package com.tangem.data.account.store + +import android.content.Context +import com.google.common.truth.Truth +import com.squareup.moshi.Moshi +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountsResponseStoreFactoryTest { + + private val context: Context = mockk() + private val moshi: Moshi = Moshi.Builder().build() + private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory( + context = context, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun setup() { + clearMocks(context) + factory.clearStores() + } + + @Test + fun `creates new data store for unique userWalletId`() { + // Arrange + val userWalletId = UserWalletId("011") + val createdStore = factory.create(userWalletId = userWalletId) + + // Actual + val actual = factory.getAllStores() + + // Assert + Truth.assertThat(actual).containsExactly(userWalletId, createdStore) + } + + @Test + fun `reuses existing data store for same userWalletId`() { + val userWalletId = UserWalletId("011") + + // Arrange (first creation) + val firstStore = factory.create(userWalletId = userWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(userWalletId, firstStore) + + // Arrange (second creation) + val secondStore = factory.create(userWalletId = userWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + Truth.assertThat(actual2).containsExactly(userWalletId, secondStore) + Truth.assertThat(firstStore).isSameInstanceAs(secondStore) + } + + @Test + fun `creates separate data stores for different userWalletIds`() { + // Arrange (first creation) + val firstWalletId = UserWalletId("011") + val firstStore = factory.create(userWalletId = firstWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore) + + // Arrange (second creation) + val secondWalletId = UserWalletId("011") + val secondStore = factory.create(userWalletId = secondWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore) + Truth.assertThat(actual2).containsExactlyEntriesIn(expected) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt index 5c61ebc3f4..03934e051e 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt @@ -1,8 +1,8 @@ package com.tangem.features.account.createedit +import com.tangem.common.ui.account.toDomain import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.ExceptionAnalyticsEvent -import com.tangem.common.ui.account.toDomain import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -170,10 +170,14 @@ internal class AccountCreateEditModel @Inject constructor( it.updateDerivationIndex(derivationIndex = derivationIndex.value) } } - .onLeft { + .onLeft { cause -> handleError( error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex, - params = mapOf("userWalletId" to userWalletId.stringValue), + message = cause.toString(), + params = mapOf( + "userWalletId" to userWalletId.stringValue, + "cause" to cause.toString(), + ), ) return@launch @@ -181,8 +185,12 @@ internal class AccountCreateEditModel @Inject constructor( } } - private fun handleError(error: AccountFeatureError, params: Map = mapOf()) { - val exception = IllegalStateException(error.toString()) + private fun handleError( + error: AccountFeatureError, + message: String? = null, + params: Map = mapOf(), + ) { + val exception = IllegalStateException("$error. Cause: $message") Timber.e(exception) From 8ee35d12f8b97ac2abd473c88cb22b1e4eb02428 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 Aug 2025 09:26:25 +0300 Subject: [PATCH 012/132] Updated on 2026-08-14 --- .../common/ui/userwallet/UserWalletItem.kt | 82 +++++++++++++------ .../converter/UserWalletItemUMConverter.kt | 3 +- .../ui/userwallet/state/UserWalletItemUM.kt | 2 + 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt index 31c66358b4..ad44dcf7dd 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt @@ -139,33 +139,59 @@ private fun NameAndInfo( ) } } - Text( - text = " $DOT ", - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - ) - AnimatedContent( - targetState = balance, - label = "Balance content", - ) { balance -> - val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + BalanceContent(balance) + } + } +} - if (balanceValue == null) { - TextShimmer( - style = TangemTheme.typography.caption2, - text = "aaaaa", - ) - } else { +@Composable +private fun BalanceContent(balance: UserWalletItemUM.Balance, modifier: Modifier = Modifier) { + AnimatedContent( + modifier = modifier, + targetState = balance, + label = "Balance content", + ) { balance -> + when (balance) { + UserWalletItemUM.Balance.Locked -> { + Icon( + modifier = Modifier + .padding(start = 4.dp, bottom = 2.dp, top = 2.dp) + .size(12.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_lock_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + UserWalletItemUM.Balance.NotShowing -> { + /** No balance and no dot */ + } + else -> { + Row { Text( - text = balanceValue, - style = TangemTheme.typography.caption2.applyBladeBrush( - isEnabled = isFlickering, - textColor = TangemTheme.colors.text.tertiary, - ), + text = " $DOT ", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, maxLines = 1, ) + + val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + + if (balanceValue == null) { + TextShimmer( + style = TangemTheme.typography.caption2, + text = "aaaaa", + ) + } else { + Text( + text = balanceValue, + style = TangemTheme.typography.caption2.applyBladeBrush( + isEnabled = isFlickering, + textColor = TangemTheme.colors.text.tertiary, + ), + maxLines = 1, + ) + } } } } @@ -246,9 +272,8 @@ fun getBalanceValueAndFlickerState(balance: UserWalletItemUM.Balance): Pair DASH_SIGN to false is UserWalletItemUM.Balance.Hidden -> THREE_STARS to false - is UserWalletItemUM.Balance.Loading -> null to false - is UserWalletItemUM.Balance.Locked -> stringResourceSafe(R.string.common_locked) to false is UserWalletItemUM.Balance.Loaded -> balance.value to balance.isFlickering + else -> null to false } } @@ -392,6 +417,15 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider UserWalletItemUM.Balance.Hidden userWallet.isLocked -> UserWalletItemUM.Balance.Locked + authMode -> UserWalletItemUM.Balance.NotShowing + isBalanceHidden -> UserWalletItemUM.Balance.Hidden balance == null -> UserWalletItemUM.Balance.Loading else -> { when (balance) { diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt index c0abb2b485..b31b4d5ddb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt @@ -28,6 +28,8 @@ data class UserWalletItemUM( data object Hidden : Balance() + data object NotShowing : Balance() + data object Locked : Balance() data object Failed : Balance() From df5af9afab63c68a435bc739e5d5964db317a57c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 Aug 2025 12:15:10 +0000 Subject: [PATCH 013/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 57e50cf4ac..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.27.0-1148" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.27.0-519" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-454" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 20dad876f08cf65244ef6a929859517b7bcc6235 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 14:49:43 +0400 Subject: [PATCH 014/132] Updated on 2026-08-14 --- .../local/datastore/RuntimeStateStore.kt | 13 ++ .../account/store/ArchivedAccountsStore.kt | 68 +++++++++ .../store/ArchivedAccountsStoreFactory.kt | 37 +++++ .../store/ArchivedAccountsStoreFactoryTest.kt | 79 ++++++++++ .../store/ArchivedAccountsStoreTest.kt | 139 ++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt index b28130b286..05f3c620af 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt @@ -14,11 +14,18 @@ interface RuntimeStateStore { /** Get flow of elements [T] */ fun get(): StateFlow + /** Get element [T] synchronously or null */ + suspend fun getSyncOrNull(): T? + /** Store [value] */ suspend fun store(value: T) + /** Update current value by [function] */ suspend fun update(function: (T) -> T) + /** Clear stored value */ + fun clear() + companion object { /** @@ -32,6 +39,8 @@ interface RuntimeStateStore { override fun get(): StateFlow = flow + override suspend fun getSyncOrNull(): T? = flow.value + override suspend fun store(value: T) { flow.value = value } @@ -39,6 +48,10 @@ interface RuntimeStateStore { override suspend fun update(function: (T) -> T) { flow.update(function) } + + override fun clear() { + flow.value = defaultValue + } } } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt new file mode 100644 index 0000000000..cbbe1ca0b9 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt @@ -0,0 +1,68 @@ +package com.tangem.data.account.store + +import androidx.annotation.VisibleForTesting +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.account.models.ArchivedAccount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.seconds + +/** + * Store for managing archived accounts with support for data expiration + * + * @property runtimeStore the underlying runtime shared store for storing the list of archived accounts + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountsStore( + private val runtimeStore: RuntimeStateStore?>, +) { + + private var timestamp: Long? = null + + /** Retrieves a flow of archived accounts, filtering out null values */ + fun get(): Flow> { + return runtimeStore.get() + .map { + if (isDataExpired()) null else it + } + .filterNotNull() + } + + /** Retrieves the list of archived accounts synchronously, or null if the data is expired */ + suspend fun getSyncOrNull(): List? { + if (isDataExpired()) return null + + return runtimeStore.getSyncOrNull() + } + + /** Stores the provided list of archived accounts [value] */ + suspend fun store(value: List) { + timestamp = System.currentTimeMillis() + + runtimeStore.store(value) + } + + private fun isDataExpired(): Boolean { + val currentTime = System.currentTimeMillis() + val storedTime = timestamp ?: return true + + return currentTime - storedTime >= EXPIRATION_DURATION_MS + } + + @VisibleForTesting + fun setTimestamp(time: Long) { + timestamp = time + } + + @VisibleForTesting + fun clear() { + timestamp = null + runtimeStore.clear() + } + + private companion object Companion { + val EXPIRATION_DURATION_MS = 120.seconds.inWholeMicroseconds + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt new file mode 100644 index 0000000000..e89a9fa130 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt @@ -0,0 +1,37 @@ +package com.tangem.data.account.store + +import androidx.annotation.VisibleForTesting +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.models.wallet.UserWalletId +import java.util.concurrent.ConcurrentHashMap + +/** + * Factory for creating and managing instances of [ArchivedAccountsStore]. + + * and reused for each unique [UserWalletId]. + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountsStoreFactory { + + private val createdRuntimeStores = ConcurrentHashMap() + + /** + * Creates or retrieves an existing instance of [ArchivedAccountsStore] for the given [userWalletId]. + * + * @param userWalletId the unique identifier for the user wallet + */ + fun create(userWalletId: UserWalletId): ArchivedAccountsStore { + return createdRuntimeStores.computeIfAbsent(userWalletId) { + ArchivedAccountsStore(runtimeStore = RuntimeStateStore(defaultValue = null)) + } + } + + @VisibleForTesting + fun getAllStores(): Map = createdRuntimeStores.toMap() + + @VisibleForTesting + fun clearStores() { + createdRuntimeStores.clear() + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt new file mode 100644 index 0000000000..977c3c4169 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt @@ -0,0 +1,79 @@ +package com.tangem.data.account.store + +import com.google.common.truth.Truth +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountsStoreFactoryTest { + + private val factory = ArchivedAccountsStoreFactory() + + @AfterEach + fun tearDownEach() { + factory.clearStores() + } + + @Test + fun `creates new store for unique userWalletId`() { + // Arrange + val userWalletId = UserWalletId("001") + val createdStore = factory.create(userWalletId) + + // Act + val actual = factory.getAllStores() + + // Assert + Truth.assertThat(actual).containsExactly(userWalletId, createdStore) + } + + @Test + fun `reuses existing data store for same userWalletId`() { + val userWalletId = UserWalletId("011") + + // Arrange (first creation) + val firstStore = factory.create(userWalletId = userWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(userWalletId, firstStore) + + // Arrange (second creation) + val secondStore = factory.create(userWalletId = userWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + Truth.assertThat(actual2).containsExactly(userWalletId, secondStore) + Truth.assertThat(firstStore).isSameInstanceAs(secondStore) + } + + @Test + fun `creates separate data stores for different userWalletIds`() { + // Arrange (first creation) + val firstWalletId = UserWalletId("011") + val firstStore = factory.create(userWalletId = firstWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore) + + // Arrange (second creation) + val secondWalletId = UserWalletId("011") + val secondStore = factory.create(userWalletId = secondWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore) + Truth.assertThat(actual2).containsExactlyEntriesIn(expected) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt new file mode 100644 index 0000000000..70e6ddb2db --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt @@ -0,0 +1,139 @@ +package com.tangem.data.account.store + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import kotlin.time.Duration.Companion.seconds + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountsStoreTest { + + private val runtimeStore: RuntimeStateStore?> = RuntimeStateStore(defaultValue = null) + private val archivedAccountsStore: ArchivedAccountsStore = ArchivedAccountsStore(runtimeStore = runtimeStore) + + @AfterEach + fun tearDown() { + archivedAccountsStore.clear() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Get { + + @Test + fun `get returns empty flow`() = runTest { + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + Truth.assertThat(actual).isEmpty() // nothing emmited + } + + @Test + fun `get returns flow with not expired data`() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + archivedAccountsStore.store(value = listOf(archivedAccount)) + + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + val expected = listOf(archivedAccount) + Truth.assertThat(actual).containsExactly(expected) + } + + @Test + fun `get returns flow with expired data`() = runTest { + // Arrange + archivedAccountsStore.store(value = listOf(createArchivedAccount())) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds) + + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + Truth.assertThat(actual).isEmpty() // nothing emmited + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetSyncOrnNull { + + @Test + fun `getSyncOrNull returns null`() = runTest { + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `get returns flow with not expired data`() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + archivedAccountsStore.store(value = listOf(archivedAccount)) + + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + val expected = archivedAccount + Truth.assertThat(actual).containsExactly(expected) + } + + @Test + fun `get returns flow with expired data`() = runTest { + // Arrange + archivedAccountsStore.store(value = listOf(createArchivedAccount())) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds) + + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).isNull() + } + } + + @Test + fun store() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + + // Act + archivedAccountsStore.store(value = listOf(archivedAccount)) + val actual = runtimeStore.getSyncOrNull() + + // Assert + val expected = archivedAccount + Truth.assertThat(actual).containsExactly(expected) + } + + private fun createArchivedAccount(): ArchivedAccount { + return ArchivedAccount( + accountId = AccountId.forCryptoPortfolio( + userWalletId = UserWalletId("011"), + derivationIndex = DerivationIndex.Main, + ), + name = AccountName("Archived Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = DerivationIndex.Main, + tokensCount = 2, + networksCount = 1, + ) + } +} \ No newline at end of file From b7eda75279589ab3c0532ef37e83d66fc4c81335 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 11:52:14 +0300 Subject: [PATCH 015/132] Updated on 2026-08-14 --- .../tangem/features/biometry/impl/model/AskBiometryModel.kt | 2 +- .../tangem/feature/wallet/child/wallet/model/WalletModel.kt | 3 +++ .../child/wallet/model/WalletsUpdateActionResolver.kt | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt index 614b462e24..195b2c9069 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt @@ -111,7 +111,6 @@ internal class AskBiometryModel @Inject constructor( private suspend fun handleSuccessAllowing(userWallet: UserWallet) { walletsRepository.saveShouldSaveUserWallets(item = true) - settingsRepository.setShouldSaveAccessCodes(value = true) if (hotWalletFeatureToggles.isHotWalletEnabled) { walletsRepository.setUseBiometricAuthentication(value = true) @@ -120,6 +119,7 @@ internal class AskBiometryModel @Inject constructor( isBiometricsRequestPolicy = walletsRepository.requireAccessCode().not(), ) } else { + settingsRepository.setShouldSaveAccessCodes(value = true) if (userWallet is UserWallet.Cold) { cardSdkConfigRepository.setAccessCodeRequestPolicy( isBiometricsRequestPolicy = userWallet.hasAccessCode, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 14324845f1..e7a00027c6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -363,6 +363,9 @@ internal class WalletModel @Inject constructor( is WalletsUpdateActionResolver.Action.RenameWallets -> { stateHolder.update(transformer = RenameWalletsTransformer(renamedWallets = action.renamedWallets)) } + WalletsUpdateActionResolver.Action.EmptyWallets -> { + Timber.w("Wallets list is empty!") + } is WalletsUpdateActionResolver.Action.Unknown -> { Timber.w("Unable to perform action: $action") } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt index 48ee23027a..3e9a4d114b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt @@ -25,6 +25,10 @@ internal class WalletsUpdateActionResolver @Inject constructor( ) { fun resolve(wallets: List, currentState: WalletScreenState): Action { + if (wallets.isEmpty()) { + return Action.EmptyWallets + } + val selectedWallet = getSelectedWalletSyncUseCase().getOrElse { /* Selected user wallet can be null after reset if remaining user wallets is locked */ return Action.Unknown @@ -319,6 +323,8 @@ internal class WalletsUpdateActionResolver @Inject constructor( } } + data object EmptyWallets : Action() + data object Unknown : Action() } } \ No newline at end of file From c6b1638ea6412fbe4ddc9fbc4bf636839280ec8e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 14:33:14 +0500 Subject: [PATCH 016/132] Updated on 2026-08-14 --- .../start/ui/AddExistingWalletStartContent.kt | 6 +++- .../hotwallet/common/ui/OptionBlock.kt | 31 +++---------------- .../walletbackup/entity/WalletBackupUM.kt | 1 + .../walletbackup/model/WalletBackupModel.kt | 5 +++ .../walletbackup/ui/WalletBackupContent.kt | 31 +++++++++++++++++-- .../model/WalletSettingsModel.kt | 2 +- 6 files changed, 45 insertions(+), 31 deletions(-) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt index 6bf693e36b..184f5f924f 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt @@ -62,7 +62,7 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi ) OptionBlock( modifier = Modifier - .padding(top = 24.dp), + .padding(top = 32.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_seed_title), description = stringResourceSafe(R.string.wallet_import_seed_description), @@ -71,6 +71,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = true, ) OptionBlock( + modifier = Modifier + .padding(top = 8.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_scan_title), description = stringResourceSafe(R.string.wallet_import_scan_description), @@ -99,6 +101,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = true, ) OptionBlock( + modifier = Modifier + .padding(top = 8.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_google_drive_title), description = stringResourceSafe(R.string.wallet_import_google_drive_description), diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt index c41dba03e8..f76ab84370 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt @@ -1,6 +1,5 @@ package com.tangem.features.hotwallet.common.ui -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -30,33 +29,11 @@ internal fun OptionBlock( backgroundColor: Color, modifier: Modifier = Modifier, ) { - val backgroundColor by animateColorAsState( - targetValue = if (enabled) { - backgroundColor - } else { - backgroundColor.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - val titleColor by animateColorAsState( - targetValue = if (enabled) { - TangemTheme.colors.text.primary1 - } else { - TangemTheme.colors.text.primary1.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - val descriptionColor by animateColorAsState( - targetValue = if (enabled) { - TangemTheme.colors.text.tertiary - } else { - TangemTheme.colors.text.tertiary.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - Column( modifier = modifier .fillMaxWidth() - .padding(top = 8.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) + .alpha(if (enabled) 1f else DISABLED_COLORS_ALPHA) .background( color = backgroundColor, shape = TangemTheme.shapes.roundedCornersXMedium, @@ -73,7 +50,7 @@ internal fun OptionBlock( .padding(end = 4.dp), text = title, style = TangemTheme.typography.subtitle1, - color = titleColor, + color = TangemTheme.colors.text.primary1, ) badge?.invoke() } @@ -82,7 +59,7 @@ internal fun OptionBlock( .padding(top = 4.dp), text = description, style = TangemTheme.typography.body2, - color = descriptionColor, + color = TangemTheme.colors.text.tertiary, ) } } \ 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/WalletBackupUM.kt index 3bc262d1a9..457edf278a 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/WalletBackupUM.kt @@ -9,6 +9,7 @@ internal data class WalletBackupUM( val googleDriveStatus: BackupStatus, val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, + val onHardwareWalletClick: () -> Unit, val backedUp: Boolean, ) 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 9996c7fb7a..20c094ed28 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 @@ -52,6 +52,7 @@ internal class WalletBackupModel @Inject constructor( googleDriveStatus = BackupStatus.ComingSoon, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, + onHardwareWalletClick = ::onHardwareWalletClick, backedUp = false, ), ) @@ -122,4 +123,8 @@ internal class WalletBackupModel @Inject constructor( uiMessageSender.send(makeBackupAtFirstAlertBS) } } + + private fun onHardwareWalletClick() { + router.push(AppRoute.UpgradeWallet(params.userWalletId)) + } } \ No newline at end of file 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 671c8dd89a..aa68dc3e7d 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 @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -25,6 +26,7 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.rows.NetworkTitle import com.tangem.core.ui.extensions.resourceReference @OptIn(ExperimentalMaterial3Api::class) @@ -47,7 +49,8 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod .padding(horizontal = 16.dp), ) { OptionBlock( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(top = 8.dp), title = stringResourceSafe(R.string.hw_backup_seed_title), description = stringResourceSafe(R.string.hw_backup_seed_description), badge = { @@ -59,7 +62,8 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod ) OptionBlock( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(top = 8.dp), title = stringResourceSafe(R.string.hw_backup_google_drive_title), description = stringResourceSafe(R.string.hw_backup_google_drive_description), badge = { @@ -69,6 +73,26 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod enabled = state.googleDriveStatus != BackupStatus.ComingSoon, backgroundColor = TangemTheme.colors.background.primary, ) + + NetworkTitle( + title = { + Text( + modifier = Modifier, + text = stringResourceSafe(R.string.express_provider_recommended), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) + OptionBlock( + modifier = Modifier.fillMaxWidth(), + title = stringResourceSafe(R.string.hw_backup_hardware_title), + description = stringResourceSafe(R.string.hw_backup_hardware_description), + badge = null, + onClick = state.onHardwareWalletClick, + enabled = true, + backgroundColor = TangemTheme.colors.background.primary, + ) } } } @@ -97,6 +121,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider Date: Thu, 28 Aug 2025 19:15:47 +0000 Subject: [PATCH 017/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 57e50cf4ac..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.27.0-1148" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.27.0-519" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-454" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 9e80fccbbda0bc411dcb2b1b8ea2b082921eff86 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 09:33:17 +0300 Subject: [PATCH 018/132] Updated on 2026-08-14 --- app/src/main/res/drawable/inset_splash.xml | 10 +++++----- app/src/main/res/values/colors.xml | 2 -- app/src/main/res/values/styles.xml | 2 +- .../core/ui/components/fields/PinTextField.kt | 18 ++++++++++-------- core/ui/src/main/res/drawable/ic_tangem_24.xml | 6 +++--- .../ui}/src/main/res/drawable/splash_logo.xml | 10 +++++----- core/ui/src/main/res/values-night/colors.xml | 4 ++++ core/ui/src/main/res/values/colors.xml | 4 ++++ .../features/welcome/impl/ui/WelcomePlain.kt | 4 ++-- 9 files changed, 34 insertions(+), 26 deletions(-) rename {app => core/ui}/src/main/res/drawable/splash_logo.xml (80%) create mode 100644 core/ui/src/main/res/values-night/colors.xml create mode 100644 core/ui/src/main/res/values/colors.xml diff --git a/app/src/main/res/drawable/inset_splash.xml b/app/src/main/res/drawable/inset_splash.xml index 1d66dc1301..2886ecbd00 100644 --- a/app/src/main/res/drawable/inset_splash.xml +++ b/app/src/main/res/drawable/inset_splash.xml @@ -1,10 +1,10 @@ + android:insetLeft="55dp" + android:insetRight="55dp" + android:insetTop="55dp" + android:insetBottom="55dp"/> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index db52974e28..bf4703e8e2 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -26,6 +26,4 @@ #1E1E1E #656565 - #000000 - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2b866d0894..d91dbb1dbe 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,7 +16,7 @@ diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt index 214da97109..a3226e0b46 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.utils.StringsSigns.PASSWORD_VISUAL_CHAR @@ -159,14 +158,17 @@ private fun CellDecoration( ) } } else { - Text( + Box( modifier = Modifier.sizeIn(minWidth = minWidth, minHeight = minHeight), - text = text, - style = TangemTheme.typography.h3, - color = color, - textAlign = TextAlign.Center, - lineHeight = 48.sp, - ) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = text, + style = TangemTheme.typography.h3, + color = color, + textAlign = TextAlign.Center, + ) + } } } } diff --git a/core/ui/src/main/res/drawable/ic_tangem_24.xml b/core/ui/src/main/res/drawable/ic_tangem_24.xml index b4ccb9cc5a..cd606d5acf 100644 --- a/core/ui/src/main/res/drawable/ic_tangem_24.xml +++ b/core/ui/src/main/res/drawable/ic_tangem_24.xml @@ -6,11 +6,11 @@ android:viewportHeight="24"> + android:fillColor="@color/icon_primary1"/> + android:fillColor="@color/icon_primary1"/> + android:fillColor="@color/icon_primary1"/> diff --git a/app/src/main/res/drawable/splash_logo.xml b/core/ui/src/main/res/drawable/splash_logo.xml similarity index 80% rename from app/src/main/res/drawable/splash_logo.xml rename to core/ui/src/main/res/drawable/splash_logo.xml index ef37dfda51..f95768bf95 100644 --- a/app/src/main/res/drawable/splash_logo.xml +++ b/core/ui/src/main/res/drawable/splash_logo.xml @@ -1,15 +1,15 @@ diff --git a/core/ui/src/main/res/values-night/colors.xml b/core/ui/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..6c7b1cabaa --- /dev/null +++ b/core/ui/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFFFF + \ No newline at end of file diff --git a/core/ui/src/main/res/values/colors.xml b/core/ui/src/main/res/values/colors.xml new file mode 100644 index 0000000000..8dd91f8060 --- /dev/null +++ b/core/ui/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FF1E1E1E + \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt index 5b80e192ee..9da4e13724 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt @@ -22,8 +22,8 @@ internal fun WelcomePlain(modifier: Modifier = Modifier) { contentAlignment = Alignment.Center, ) { Icon( - modifier = Modifier.size(84.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + modifier = Modifier.size(82.dp + 11.dp), + imageVector = ImageVector.vectorResource(R.drawable.splash_logo), tint = TangemTheme.colors.icon.primary1, contentDescription = null, ) From 665c0ff7631dd3ffbefec460fd067d008e50394b Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 14:35:24 +0500 Subject: [PATCH 019/132] Updated on 2026-08-14 --- .../tangem/tap/di/hot/TangemHotSdkModule.kt | 6 ++++++ .../hot/DefaultHotMapDerivationsRepository.kt | 1 + ...Accessor.kt => DefaultHotWalletAccessor.kt} | 18 ++++++++++-------- .../data/wallets/hot/TangemHotWalletSigner.kt | 1 + .../domain/wallets/hot/HotWalletAccessor.kt | 10 ++++++++++ 5 files changed, 28 insertions(+), 8 deletions(-) rename data/wallets/src/main/java/com/tangem/data/wallets/hot/{HotWalletAccessor.kt => DefaultHotWalletAccessor.kt} (92%) create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt diff --git a/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt b/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt index c21b55928b..53110dc9c9 100644 --- a/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt +++ b/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt @@ -1,5 +1,7 @@ package com.tangem.tap.di.hot +import com.tangem.data.wallets.hot.DefaultHotWalletAccessor +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.TangemHotSdk import com.tangem.tap.features.hot.TangemHotSDKProxy import dagger.Binds @@ -15,4 +17,8 @@ internal interface TangemHotSdkModule { @Binds @Singleton fun bindTangemHotSdk(proxy: TangemHotSDKProxy): TangemHotSdk + + @Binds + @Singleton + fun bindHotWalletAccessor(default: DefaultHotWalletAccessor): HotWalletAccessor } \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt index 3eb9431fc3..bdc3f7fdf2 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.usecase.BackendId import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.operations.derivation.ExtendedPublicKeysMap diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt similarity index 92% rename from data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt rename to data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index b5ca40b2de..796bc8d3ba 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -3,7 +3,7 @@ package com.tangem.data.wallets.hot import com.tangem.common.core.TangemSdkError import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.copy +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.hot.sdk.TangemHotSdk @@ -11,22 +11,24 @@ import com.tangem.hot.sdk.exception.WrongPasswordException import com.tangem.hot.sdk.model.* import javax.inject.Inject -class HotWalletAccessor @Inject constructor( +class DefaultHotWalletAccessor @Inject constructor( private val tangemHotSdk: TangemHotSdk, private val userWalletsListRepository: UserWalletsListRepository, private val hotWalletPasswordRequester: HotWalletPasswordRequester, private val walletsRepository: WalletsRepository, -) { +) : HotWalletAccessor { - suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = + override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = hotSdkRequest(hotWalletId) { unlock -> tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign) } - suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse = - hotSdkRequest(hotWalletId) { unlock -> - tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request) - } + override suspend fun derivePublicKeys( + hotWalletId: HotWalletId, + request: DeriveWalletRequest, + ): DerivedPublicKeyResponse = hotSdkRequest(hotWalletId) { unlock -> + tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request) + } private suspend fun hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T { val isAccessCodeRequired = walletsRepository.requireAccessCode() diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt index b5bfc9da12..ccef106815 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt @@ -6,6 +6,7 @@ import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError import com.tangem.common.map import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.model.DataToSign import com.tangem.operations.sign.SignData import dagger.assisted.Assisted diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt new file mode 100644 index 0000000000..18d9d792fe --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.wallets.hot + +import com.tangem.hot.sdk.model.* + +interface HotWalletAccessor { + + suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List + + suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse +} \ No newline at end of file From d48ba8a8bdd704e04858db80712de9cf5aad7e07 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 14:52:25 +0400 Subject: [PATCH 020/132] Updated on 2026-08-14 --- .../java/com/tangem/tap/TangemApplication.kt | 7 +- core/config-toggles/build.gradle.kts | 87 +++- .../di/FeatureTogglesManagerModule.kt | 20 +- .../feature/FeatureTogglesManager.kt | 3 - .../feature/impl/DevFeatureTogglesManager.kt | 80 +-- .../feature/impl/ProdFeatureTogglesManager.kt | 31 +- .../storage/FeatureTogglesLocalStorage.kt | 31 ++ .../core/configtoggle/utils/CollectionExt.kt | 10 + .../manager/DevFeatureTogglesManagerTest.kt | 489 ++++++++++++++++++ .../manager/DevTogglesManagerTest.kt | 237 --------- .../manager/ProdFeatureTogglesManagerTest.kt | 155 ++++++ .../manager/ProdTogglesManagerTest.kt | 136 ----- .../local/preferences/PreferencesKeys.kt | 2 - 13 files changed, 829 insertions(+), 459 deletions(-) create mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt create mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt delete mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt create mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt delete mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 09c556cae1..a413d65b34 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -77,6 +77,7 @@ import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints import kotlinx.coroutines.* import org.rekotlin.Store +import timber.log.Timber import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository lateinit var store: Store @@ -289,13 +290,17 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat tangemAppLoggerInitializer.initialize() + Timber.i("APP STARTED") + if (BuildConfig.TESTER_MENU_ENABLED) { + Timber.i(featureTogglesManager.toString()) + } + foregroundActivityObserver = ForegroundActivityObserver() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them. runBlocking { awaitAll( - async { featureTogglesManager.init() }, async { excludedBlockchainsManager.init() }, ) initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) diff --git a/core/config-toggles/build.gradle.kts b/core/config-toggles/build.gradle.kts index 96fb87b639..368af3bc77 100644 --- a/core/config-toggles/build.gradle.kts +++ b/core/config-toggles/build.gradle.kts @@ -1,4 +1,6 @@ -import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants +import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import org.json.JSONArray plugins { alias(deps.plugins.android.library) @@ -9,8 +11,24 @@ plugins { id("configuration") } +buildscript { + dependencies { + classpath("com.squareup:kotlinpoet:1.15.0") + classpath("org.json:json:20231013") + } +} + android { namespace = "com.tangem.core.configtoggle" + sourceSets["main"].java.srcDir("build/generated/source/toggles") +} + +tasks.named("preBuild") { + dependsOn(generateFeatureToggles /*generateExcludedBlockchainToggles*/) +} + +tasks.withType().configureEach { + useJUnitPlatform() } dependencies { @@ -32,7 +50,72 @@ dependencies { implementation(projects.core.utils) testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) + testImplementation(projects.common.test) +} + +val generateFeatureToggles by tasks.registering { + generateToggles( + inputFilePath = "src/main/assets/configs/feature_toggles_config.json", + generatedFileName = "FeatureToggles", + ) +} + +val generateExcludedBlockchainToggles by tasks.registering { + generateToggles( + inputFilePath = "src/main/assets/configs/excluded_blockchains_config.json", + generatedFileName = "ExcludedBlockchainToggles", + ) +} + +fun Task.generateToggles(inputFilePath: String, generatedFileName: String) { + val inputFile = file(inputFilePath) + val outputDir = file("build/generated/source/toggles") + + inputs.file(inputFile) + outputs.dir(outputDir) + + doLast { + val jsonText = inputFile.readText() + val jsonArray = JSONArray(jsonText) + + val entries = (0 until jsonArray.length()).map { i -> + val obj = jsonArray.getJSONObject(i) + val name = obj.getString("name") + val version = obj.getString("version") + CodeBlock.of("%S to %S", name, version) + } + + val mapInitializer = CodeBlock.builder() + .add("mapOf(\n") + .indent() + .apply { + entries.forEachIndexed { index, entry -> + add(entry) + if (index != entries.lastIndex) add(",\n") else add("\n") + } + } + .unindent() + .add(")") + .build() + + val objectBuilder = TypeSpec.objectBuilder(name = generatedFileName) + .addKdoc("Generated from $inputFilePath") + .addProperty( + PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING)) + .initializer(mapInitializer) + .build() + ) + + val fileSpec = FileSpec.builder(packageName = "com.tangem.core.configtoggle", fileName = generatedFileName) + .addType(objectBuilder.build()) + .build() + + val outputPackageDir = File(outputDir, "") + outputPackageDir.mkdirs() + fileSpec.writeTo(outputPackageDir) + } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt index fa5c04f6a1..79ad0221fc 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt @@ -5,16 +5,14 @@ import com.tangem.core.configtoggle.BuildConfig import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager -import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage import com.tangem.core.configtoggle.version.DefaultVersionProvider -import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.runBlocking import javax.inject.Singleton @Module @@ -25,29 +23,17 @@ internal object FeatureTogglesManagerModule { @Singleton fun provideFeatureTogglesManager( @ApplicationContext context: Context, - assetLoader: AssetLoader, appPreferencesStore: AppPreferencesStore, ): FeatureTogglesManager { - val localTogglesStorage = LocalTogglesStorage(assetLoader) val versionProvider = DefaultVersionProvider(context) return if (BuildConfig.TESTER_MENU_ENABLED) { DevFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - appPreferencesStore = appPreferencesStore, versionProvider = versionProvider, + featureTogglesLocalStorage = FeatureTogglesLocalStorage(appPreferencesStore), ) } else { - ProdFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - versionProvider = versionProvider, - ) - }.also { - // We need to initialize during the hilt graph creation - // in order to provide the feature toggles correctly to other dependencies. - runBlocking { - it.init() - } + ProdFeatureTogglesManager(versionProvider = versionProvider) } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt index 86a027924c..0296565727 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt @@ -7,9 +7,6 @@ package com.tangem.core.configtoggle.feature */ interface FeatureTogglesManager { - /** Initialize manager */ - suspend fun init() - /** Check feature toggle availability by name [name] */ fun isFeatureEnabled(name: String): Boolean } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index ce0d035439..6facf5dc8b 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -1,77 +1,77 @@ package com.tangem.core.configtoggle.feature.impl import androidx.annotation.VisibleForTesting +import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles +import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage +import com.tangem.core.configtoggle.utils.defineTogglesAvailability import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.storeObject +import kotlinx.coroutines.runBlocking +import java.util.Locale +import kotlin.properties.Delegates /** * Feature toggles manager implementation in DEV build * - * @property localTogglesStorage local feature toggles storage - * @property appPreferencesStore application local store * @property versionProvider application version provider + * @property featureTogglesLocalStorage local storage for feature toggles */ internal class DevFeatureTogglesManager( - private val localTogglesStorage: TogglesStorage, - private val appPreferencesStore: AppPreferencesStore, private val versionProvider: VersionProvider, + private val featureTogglesLocalStorage: FeatureTogglesLocalStorage, ) : MutableFeatureTogglesManager { - private var featureTogglesMap: MutableMap? = null - private var localFeatureTogglesMap: Map? = null + private var fileFeatureTogglesMap: Map = getFileFeatureToggles() + private var featureTogglesMap: MutableMap by Delegates.notNull() - override suspend fun init() { - if (featureTogglesMap != null && localFeatureTogglesMap != null) { - return // Already initialized - } + init { + val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() } - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull>( - key = PreferencesKeys.FEATURE_TOGGLES_KEY, - ) ?: emptyMap() - - val localFeatureToggles = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get().orEmpty()) - - localFeatureTogglesMap = localFeatureToggles - - featureTogglesMap = localFeatureToggles + featureTogglesMap = fileFeatureTogglesMap .mapValues { resultToggle -> savedFeatureToggles[resultToggle.key] ?: resultToggle.value } .toMutableMap() } - override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap!![name] ?: false + override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] == true - override fun isMatchLocalConfig(): Boolean = featureTogglesMap == localFeatureTogglesMap + override fun getFeatureToggles(): Map = featureTogglesMap - override fun getFeatureToggles(): Map = featureTogglesMap!! + override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap override suspend fun changeToggle(name: String, isEnabled: Boolean) { - featureTogglesMap!![name] ?: return - featureTogglesMap!![name] = isEnabled - appPreferencesStore.storeFeatureToggles(value = featureTogglesMap!!) + featureTogglesMap[name] ?: return + featureTogglesMap[name] = isEnabled + featureTogglesLocalStorage.store(value = featureTogglesMap) } override suspend fun recoverLocalConfig() { - featureTogglesMap = localFeatureTogglesMap!!.toMutableMap() - appPreferencesStore.storeFeatureToggles(value = localFeatureTogglesMap!!) + featureTogglesMap = fileFeatureTogglesMap.toMutableMap() + featureTogglesLocalStorage.store(value = fileFeatureTogglesMap) + } + + override fun toString(): String { + return buildString { + append("DevFeatureTogglesManager:\n") + append("|------------------------------------------|-----------|\n") + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled")) + append("|------------------------------------------|-----------|\n") + featureTogglesMap.entries.forEachIndexed { index, (name, isEnabled) -> + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled)) + } + append("|------------------------------------------|-----------|") + } + } + + private fun getFileFeatureToggles(): Map { + val appVersion = versionProvider.get() + + return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion) } @VisibleForTesting(otherwise = VisibleForTesting.NONE) fun setFeatureToggles(map: MutableMap) { featureTogglesMap = map } - - private suspend fun AppPreferencesStore.storeFeatureToggles(value: Map) { - storeObject(PreferencesKeys.FEATURE_TOGGLES_KEY, value) - } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt index 54fe7a011d..261ba22e8e 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt @@ -1,41 +1,30 @@ package com.tangem.core.configtoggle.feature.impl import androidx.annotation.VisibleForTesting +import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles +import com.tangem.core.configtoggle.utils.defineTogglesAvailability import com.tangem.core.configtoggle.version.VersionProvider /** * Feature toggles manager implementation in PROD build * - * @property localTogglesStorage local feature toggles storage - * @property versionProvider application version provider + * @property versionProvider application version provider */ internal class ProdFeatureTogglesManager( - private val localTogglesStorage: TogglesStorage, private val versionProvider: VersionProvider, ) : FeatureTogglesManager { - private var featureToggles: Map? = null + private val featureToggles: Map = getFileFeatureToggles() - override suspend fun init() { - if (featureToggles != null) { - return // Already initialized - } + override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] == true - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - featureToggles = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get() ?: "") + private fun getFileFeatureToggles(): Map { + val appVersion = versionProvider.get() + + return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion) } - override fun isFeatureEnabled(name: String): Boolean = featureToggles!![name] ?: false - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun getProdFeatureToggles() = featureToggles!! - - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun setProdFeatureToggles(map: Map) { - featureToggles = map - } + fun getProdFeatureToggles() = featureToggles } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt new file mode 100644 index 0000000000..7ab7d42891 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt @@ -0,0 +1,31 @@ +package com.tangem.core.configtoggle.storage + +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.datasource.local.preferences.utils.storeObjectMap + +/** + * Local storage for feature toggles + * + * @property appPreferencesStore app preferences store + * +[REDACTED_AUTHOR] + */ +internal class FeatureTogglesLocalStorage( + private val appPreferencesStore: AppPreferencesStore, +) { + + suspend fun getSyncOrEmpty(): Map { + return appPreferencesStore.getObjectMapSync(key = FEATURE_TOGGLES_KEY) + } + + suspend fun store(value: Map) { + appPreferencesStore.storeObjectMap(key = FEATURE_TOGGLES_KEY, value = value) + } + + private companion object { + + val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt index 68ee66b88c..d3e54bcac1 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt @@ -10,4 +10,14 @@ internal fun List.associateToggles(currentVersion: String): Map.defineTogglesAvailability(appVersion: String?): Map { + return if (appVersion == null) { + mapValues { false } + } else { + mapValues { (_, version) -> + VersionAvailabilityContract(currentVersion = appVersion, localVersion = version) + } + } } \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt new file mode 100644 index 0000000000..e91cc5d705 --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt @@ -0,0 +1,489 @@ +package com.tangem.core.configtoggle.manager + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager +import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel +import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DevFeatureTogglesManagerTest { + + private val versionProvider = mockk() + private val featureTogglesLocalStorage = mockk(relaxUnitFun = true) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0") + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(FeatureToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider, featureTogglesLocalStorage) + } + + @Test + fun `successfully initialize manager`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns null`() = runTest { + // Arrange + val appVersion = null + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns empty string`() = runTest { + // Arrange + val appVersion = "" + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + coVerify(inverse = true) { featureTogglesLocalStorage.getSyncOrEmpty() } + } + + @Test + fun `successfully initialize manager if storage returns empty map`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = emptyMap() + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if storage returns unknown toggles`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = mapOf("TOGGLE_3" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if storage throws exception`() = runTest { + // Arrange + val appVersion = "1.0.0" + val exception = Exception("Test exception") + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } throws exception + + // Act + val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsFeatureEnabled { + + private lateinit var manager: DevFeatureTogglesManager + + @BeforeAll + fun setupAll() { + every { versionProvider.get() } returns "1.0.0" + + val featureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + } + + @AfterAll + fun tearDownAll() { + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + @ParameterizedTest + @ProvideTestModels + fun isFeatureEnabled(model: IsFeatureEnabledModel) { + // Act + val actual = manager.isFeatureEnabled(name = model.name) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = true), + IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "", expected = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsMatchLocalConfig { + + @ParameterizedTest + @ProvideTestModels + fun isMatchLocalConfig(model: IsMatchLocalConfigModel) = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns model.fileFeatureToggles + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).apply { + setFeatureToggles(model.storedFeatureToggles.toMutableMap()) + } + + // Act + val actual = manager.isMatchLocalConfig() + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + private fun provideTestModels() = listOf( + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false), + expected = true, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_3" to true, "TOGGLE_4" to false), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = emptyMap(), + storedFeatureToggles = emptyMap(), + expected = true, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = emptyMap(), + storedFeatureToggles = mapOf("TOGGLE_1" to true), + expected = false, + ), + ) + } + + data class IsMatchLocalConfigModel( + val fileFeatureToggles: Map, + val storedFeatureToggles: Map, + val expected: Boolean, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetFeatureToggles { + + @Test + fun getFeatureToggles() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val fileFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns fileFeatureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + val actual = manager.getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ChangeToggle { + + @ParameterizedTest + @ProvideTestModels + fun changeToggle(model: ChangeToggleModel) = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns model.initialToggles + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + manager.changeToggle(name = model.name, isEnabled = model.isEnabled) + val actual = manager.getFeatureToggles() + + // Assert + val expected = model.expectedToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + + if (model.expectedStoreSaving) { + featureTogglesLocalStorage.store(model.expectedToggles) + } + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + private fun provideTestModels() = listOf( + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_1", + isEnabled = false, + expectedToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false), + expectedStoreSaving = true, + ), + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_2", + isEnabled = true, + expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true), + expectedStoreSaving = true, + ), + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_3", + isEnabled = true, + expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false), + expectedStoreSaving = false, + ), + ChangeToggleModel( + initialToggles = emptyMap(), + name = "TOGGLE_1", + isEnabled = true, + expectedToggles = emptyMap(), + expectedStoreSaving = false, + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class RecoverLocalConfig { + + @Test + fun recoverLocalConfig() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val fileFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns fileFeatureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + manager.recoverLocalConfig() + val actual = manager.getFeatureToggles() + + // Assert + val expected = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to false, + "ACTIVE2_TEST_FEATURE_ENABLED" to true, + ) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + featureTogglesLocalStorage.store(expected) + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + } + + data class ChangeToggleModel( + val initialToggles: Map, + val name: String, + val isEnabled: Boolean, + val expectedToggles: Map, + val expectedStoreSaving: Boolean, + ) +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt deleted file mode 100644 index a4c3d6d7aa..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.tangem.core.configtoggle.manager - -import android.annotation.SuppressLint -import com.google.common.truth.Truth -import com.squareup.moshi.Moshi -import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.core.configtoggle.storage.ConfigToggle -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.getSyncOrNull -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.collections.set - -/** -[REDACTED_AUTHOR] - */ -@SuppressLint("CheckResult") -internal class DevTogglesManagerTest { - - private val localTogglesStorage = mockk() - private val appPreferenceStore = AppPreferencesStore( - moshi = Moshi.Builder().build(), - dispatchers = TestingCoroutineDispatcherProvider(), - preferencesDataStore = mockk(relaxed = true), - ) - private val versionProvider = mockk() - private val manager = DevFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - appPreferencesStore = appPreferenceStore, - versionProvider = versionProvider, - ) - - @Test - fun `successfully initialize storage if shared prefs kept feature toggles`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns savedFeatureTogglesMap - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if shared prefs kept empty list`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns emptyMap() - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if shared prefs didn't keep feature toggles`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns null - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues(Map.Entry::value) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if versionProvider returns null`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { appPreferenceStore.getSyncOrNull(PreferencesKeys.FEATURE_TOGGLES_KEY) } returns savedFeatureToggles - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns null - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion = "") - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `get feature availability if feature toggle exists`() { - val featureToggles = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to true, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED") - - Truth.assertThat(actual).isTrue() - } - - @Test - fun `get feature availability if feature toggle doesn't exists`() { - val featureToggles = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "") - - Truth.assertThat(actual).isFalse() - } - - @Test - fun getFeatureToggles() { - val expected = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(expected) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `change toggle that contains in map`() = runTest { - val changeableToggleName = "INACTIVE_TEST_FEATURE_ENABLED" - val resultMap = mutableMapOf( - changeableToggleName to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(resultMap) - - manager.changeToggle(changeableToggleName, true) - - resultMap[changeableToggleName] = true - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap) - } - - @Test - fun `change toggle that doesn't contains in map`() = runTest { - val resultMap = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(resultMap) - - manager.changeToggle("FEATURE_TOGGLE", true) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap) - } - - private companion object { - - val savedFeatureToggles = """ - [ - { - "name": "INACTIVE_TEST_FEATURE_ENABLED", - "version": "undefined" - }, - { - "name": "ACTIVE2_TEST_FEATURE_ENABLED", - "version": "1.0.0" - } - ] - """.trimIndent() - - val savedFeatureTogglesMap = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - val localFeatureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - } -} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt new file mode 100644 index 0000000000..ffef24d22e --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt @@ -0,0 +1,155 @@ +package com.tangem.core.configtoggle.manager + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ProdFeatureTogglesManagerTest { + + private val versionProvider = mockk() + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0") + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(FeatureToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider) + } + + @Test + fun `successfully initialize storage`() = runTest { + // Arrange + val appVersion = "1.0.0" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `successfully initialize storage if versionProvider returns null`() = runTest { + // Arrange + val appVersion = null + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `successfully initialize storage if versionProvider returns empty string`() = runTest { + // Arrange + val appVersion = "" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `failure initialize storage if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsFeatureEnabled { + + private lateinit var manager: ProdFeatureTogglesManager + + @BeforeAll + fun setupAll() { + every { versionProvider.get() } returns "1.0.0" + + val featureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + + manager = ProdFeatureTogglesManager(versionProvider) + } + + @AfterAll + fun tearDownAll() { + clearMocks(versionProvider) + unmockkObject(FeatureToggles) + } + + @ParameterizedTest + @ProvideTestModels + fun isFeatureEnabled(model: IsFeatureEnabledModel) { + // Act + val actual = manager.isFeatureEnabled(name = model.name) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = true), + IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "", expected = false), + ) + } + + data class IsFeatureEnabledModel(val name: String, val expected: Boolean) +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt deleted file mode 100644 index 6453d3eb4d..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.tangem.core.configtoggle.manager - -import android.content.pm.PackageManager -import com.google.common.truth.Truth -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager -import com.tangem.core.configtoggle.storage.ConfigToggle -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test - -/** -[REDACTED_AUTHOR] - */ -internal class ProdTogglesManagerTest { - - private val localTogglesStorage = mockk() - private val versionProvider = mockk() - private val manager = ProdFeatureTogglesManager(localTogglesStorage, versionProvider) - - @Test - fun `successfully initialize storage`() = runTest { - val currentVersion = "1.0.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles.associateToggles(currentVersion) - Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if versionProvider returns null`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } returns null - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(disabledFeatureToggles) - } - - @Test - fun `failure initialize storage if localFeatureTogglesStorage throws exception`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } throws IllegalStateException( - "Property featureToggles should be initialized before get.", - ) - - runCatching { manager.init() } - .onSuccess { throw IllegalStateException("localFeatureToggles shouldn't be initialized") } - .onFailure { - Truth - .assertThat(it) - .hasMessageThat() - .contains("Property featureToggles should be initialized before get.") - - Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java) - } - - coVerifyOrder { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } - verifyAll(inverse = true) { versionProvider.get() } - } - - @Test - fun `failure initialize storage if versionProvider throws exception`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } throws PackageManager.NameNotFoundException() - - runCatching { manager.init() } - .onSuccess { throw IllegalStateException("versionProvider should throws exception") } - .onFailure { - Truth.assertThat(it).isInstanceOf(PackageManager.NameNotFoundException::class.java) - } - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - } - - @Test - fun `get feature availability if feature toggle exists`() { - val featureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to true, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setProdFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED") - - Truth.assertThat(actual).isTrue() - } - - @Test - fun `get feature availability if feature toggle doesn't exists`() { - val featureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setProdFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "") - - Truth.assertThat(actual).isFalse() - } - - private companion object { - val localFeatureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - - val disabledFeatureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 06a5f51102..162d75a40f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -56,8 +56,6 @@ object PreferencesKeys { val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } - val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } - val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") } val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") } From 3d5a8a55ed9e060a9f1d0ed2f6a837d121ac0511 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 Aug 2025 14:03:04 +0400 Subject: [PATCH 021/132] Updated on 2026-08-14 --- .../api/tangemTech/TangemTechApi.kt | 3 +- .../AccountConverterFactoryContainer.kt | 22 +- .../data/account/di/AccountDataModule.kt | 20 +- .../DefaultAccountsCRUDRepository.kt | 150 ++-- .../store/ArchivedAccountsStoreFactory.kt | 2 +- .../DefaultAccountsCRUDRepositoryTest.kt | 664 ++++++++++++++++++ .../store/ArchivedAccountsStoreFactoryTest.kt | 2 +- .../repository/AccountsCRUDRepository.kt | 10 +- .../usecase/AddCryptoPortfolioUseCase.kt | 4 +- .../usecase/ArchiveCryptoPortfolioUseCase.kt | 2 +- .../usecase/GetArchivedAccountsUseCase.kt | 8 +- .../GetUnoccupiedAccountIndexUseCase.kt | 5 + .../usecase/RecoverCryptoPortfolioUseCase.kt | 4 +- .../usecase/UpdateCryptoPortfolioUseCase.kt | 2 +- .../usecase/AddCryptoPortfolioUseCaseTest.kt | 20 +- .../ArchiveCryptoPortfolioUseCaseTest.kt | 20 +- .../usecase/GetArchivedAccountsUseCaseTest.kt | 16 +- .../GetUnoccupiedAccountIndexUseCaseTest.kt | 3 +- .../RecoverCryptoPortfolioUseCaseTest.kt | 44 +- .../UpdateCryptoPortfolioUseCaseTest.kt | 32 +- 20 files changed, 899 insertions(+), 134 deletions(-) create mode 100644 data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index be6fe202e7..9a557cd922 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -154,7 +154,8 @@ interface TangemTechApi { suspend fun saveWalletAccounts( @Path("walletId") walletId: String, @Header("If-Match") ifMatch: String, - ): ApiResponse + @Body body: SaveWalletAccountsResponse, + ): ApiResponse @GET("/v1/wallets/{walletId}/accounts/archived") suspend fun getWalletArchivedAccounts( diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt index 28cfd01b9e..e8bcef8cce 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt @@ -1,5 +1,7 @@ package com.tangem.data.account.converter +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWalletId import javax.inject.Inject /** @@ -14,7 +16,21 @@ import javax.inject.Inject [REDACTED_AUTHOR] */ internal class AccountConverterFactoryContainer @Inject constructor( - val accountsListCF: AccountListConverter.Factory, val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory, - val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, -) \ No newline at end of file + private val accountsListCF: AccountListConverter.Factory, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userWalletsStore: UserWalletsStore, +) { + + fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + return accountsListCF.create(userWallet) + } + + fun createCryptoPortfolioConverter(userWalletId: UserWalletId): CryptoPortfolioConverter { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + return cryptoPortfolioCF.create(userWallet) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index e05c402f68..df872e31dc 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,9 +1,13 @@ package com.tangem.data.account.di +import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.repository.DefaultAccountsCRUDRepository -import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,10 +20,20 @@ internal object AccountDataModule { @Provides @Singleton - fun provideAccountsCRUDRepository(userWalletsStore: UserWalletsStore): AccountsCRUDRepository { + fun provideAccountsCRUDRepository( + tangemTechApi: TangemTechApi, + accountsResponseStoreFactory: AccountsResponseStoreFactory, + userWalletsStore: UserWalletsStore, + accountConverterFactoryContainer: AccountConverterFactoryContainer, + dispatchers: CoroutineDispatcherProvider, + ): AccountsCRUDRepository { return DefaultAccountsCRUDRepository( - runtimeStore = RuntimeSharedStore(), + tangemTechApi = tangemTechApi, + accountsResponseStoreFactory = accountsResponseStoreFactory, + archivedAccountsStoreFactory = ArchivedAccountsStoreFactory, userWalletsStore = userWalletsStore, + convertersContainer = accountConverterFactoryContainer, + dispatchers = dispatchers, ) } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index a9fba30f57..3d148930d5 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -1,90 +1,150 @@ package com.tangem.data.account.repository import arrow.core.Option -import arrow.core.Option.Companion.catch -import arrow.core.none import arrow.core.raise.option -import com.tangem.datasource.local.datastore.RuntimeSharedStore +import arrow.core.toOption +import com.tangem.data.account.converter.AccountConverterFactoryContainer +import com.tangem.data.account.converter.ArchivedAccountConverter +import com.tangem.data.account.converter.SaveWalletAccountsResponseConverter +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStore +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository -import com.tangem.domain.models.account.* +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.extensions.addOrReplace +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** [REDACTED_AUTHOR] */ -// TODO: [REDACTED_JIRA] internal class DefaultAccountsCRUDRepository( - private val runtimeStore: RuntimeSharedStore>, + private val tangemTechApi: TangemTechApi, + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory, private val userWalletsStore: UserWalletsStore, + private val convertersContainer: AccountConverterFactoryContainer, + private val dispatchers: CoroutineDispatcherProvider, ) : AccountsCRUDRepository { - override suspend fun getAccounts(userWalletId: UserWalletId): Option = catch { - runtimeStore.getSyncOrNull() - ?.firstOrNull { it.userWallet.walletId == userWalletId } - ?: return none() + private val saveAccountsMutex = Mutex() + + override suspend fun getAccountListSync(userWalletId: UserWalletId): Option = option { + val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) + + ensureNotNull(accountListResponse) + + val converter = convertersContainer.createAccountListConverter(userWalletId = userWalletId) + converter.convert(value = accountListResponse) } - override suspend fun getAccount(accountId: AccountId): Option = catch { - runtimeStore.getSyncOrNull().orEmpty() - .flatMap { it.accounts } - .firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio - ?: return none() + override suspend fun getAccountSync(accountId: AccountId): Option = option { + val userWalletId = accountId.userWalletId + + val accountResponse = getAccountsResponseSync(userWalletId = userWalletId) + ?.accounts?.firstOrNull { it.id == accountId.value } + + ensureNotNull(accountResponse) + + val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = userWalletId) + converter.convert(value = accountResponse) } - override suspend fun getArchivedAccount(accountId: AccountId): Option = option { - createMockArchivedAccount(userWalletId = accountId.userWalletId) + override suspend fun getArchivedAccountSync(accountId: AccountId): Option { + val store = getArchivedAccountsStore(userWalletId = accountId.userWalletId) + + return store.getSyncOrNull() + ?.firstOrNull { it.accountId == accountId } + .toOption() } - override suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option> = option { - listOf( - createMockArchivedAccount(userWalletId), - ) + override suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option> { + val store = getArchivedAccountsStore(userWalletId = userWalletId) + + return store.getSyncOrNull().toOption() } override fun getArchivedAccounts(userWalletId: UserWalletId): Flow> { - return flow { - getArchivedAccountsSync(userWalletId).getOrNull().orEmpty() - } + val store = getArchivedAccountsStore(userWalletId = userWalletId) + + return store.get() } - override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) = Unit + override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) { + val response = withContext(dispatchers.io) { + tangemTechApi.getWalletArchivedAccounts(walletId = userWalletId.stringValue).getOrThrow() + } + + val store = getArchivedAccountsStore(userWalletId = userWalletId) + val converter = ArchivedAccountConverter(userWalletId = userWalletId) + + val archivedAccounts = converter.convertList(input = response.accounts) + + store.store(value = archivedAccounts) + } override suspend fun saveAccounts(accountList: AccountList) { - runtimeStore.update(emptyList()) { - it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId } + saveAccountsMutex.withLock { + val store = getAccountsResponseStore(userWalletId = accountList.userWallet.walletId) + + val version = store.data.firstOrNull()?.wallet?.version ?: 0 + val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + + withContext(dispatchers.io) { + tangemTechApi.saveWalletAccounts( + walletId = accountList.userWallet.walletId.stringValue, + ifMatch = version.toString(), + body = body, + ) + .getOrThrow() + } + + val converter = convertersContainer.getWalletAccountsResponseCF.create( + userWallet = accountList.userWallet, + version = version, + ) + + val accountsResponse = converter.convert(value = accountList) + + store.updateData { accountsResponse } } } - override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int { - val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1 + override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option = option { + val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) - return activeAccountsCount + 1 + ensureNotNull(accountListResponse) + + return accountListResponse.wallet.totalAccounts.toOption() } override fun getUserWallet(userWalletId: UserWalletId): UserWallet { return userWalletsStore.getSyncStrict(userWalletId) } - private fun createMockArchivedAccount(userWalletId: UserWalletId): ArchivedAccount { - val derivationIndex = DerivationIndex(value = 1000).getOrNull()!! + private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? { + val store = getAccountsResponseStore(userWalletId = userWalletId) + return store.data.firstOrNull() + } - return ArchivedAccount( - accountId = AccountId.forCryptoPortfolio( - userWalletId = userWalletId, - derivationIndex = derivationIndex, - ), - name = AccountName("Archived Account").getOrNull()!!, - icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), - derivationIndex = derivationIndex, - tokensCount = 2, - networksCount = 1, - ) + private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore { + return accountsResponseStoreFactory.create(userWalletId = userWalletId) + } + + private fun getArchivedAccountsStore(userWalletId: UserWalletId): ArchivedAccountsStore { + return archivedAccountsStoreFactory.create(userWalletId) } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt index e89a9fa130..96e522270b 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt @@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap * [REDACTED_AUTHOR] */ -internal class ArchivedAccountsStoreFactory { +internal object ArchivedAccountsStoreFactory { private val createdRuntimeStores = ConcurrentHashMap() diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt new file mode 100644 index 0000000000..0002eb553d --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -0,0 +1,664 @@ +package com.tangem.data.account.repository + +import arrow.core.None +import arrow.core.toOption +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.account.converter.* +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStore +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.account.Account.CryptoPortfolio +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import kotlin.time.Duration.Companion.minutes + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultAccountsCRUDRepositoryTest { + + private val tangemTechApi: TangemTechApi = mockk() + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory = mockk() + private val archivedAccountsInnerStore = RuntimeStateStore?>(defaultValue = null) + private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore) + + private val userWalletsStore: UserWalletsStore = mockk() + + private val convertersContainer: AccountConverterFactoryContainer = mockk() + private val accountListConverter: AccountListConverter = mockk() + private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk() + + private val repository = DefaultAccountsCRUDRepository( + tangemTechApi = tangemTechApi, + accountsResponseStoreFactory = accountsResponseStoreFactory, + archivedAccountsStoreFactory = archivedAccountsStoreFactory, + userWalletsStore = userWalletsStore, + convertersContainer = convertersContainer, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + + @BeforeAll + fun setup() { + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + + every { convertersContainer.createAccountListConverter(userWalletId) } returns accountListConverter + every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + every { archivedAccountsStoreFactory.create(userWalletId) } returns archivedAccountsStore + } + + @AfterEach + fun tearDownEach() { + accountsResponseStoreFlow.value = null + archivedAccountsInnerStore.clear() + + clearMocks( + tangemTechApi, + archivedAccountsStoreFactory, + accountListConverter, + cryptoPortfolioConverter, + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountListSync { + + @Test + fun `getAccounts should return None when account list response is null`() = runTest { + // Arrange + accountsResponseStoreFlow.value = null + + // Act + val actual = repository.getAccountListSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { accountListConverter.convert(value = any()) } + } + + @Test + fun `getAccounts should return AccountList when account list response is not null`() = runTest { + // Arrange + val response = mockk() + val accountList = mockk() + + accountsResponseStoreFlow.value = response + + every { accountListConverter.convert(response) } returns accountList + + // Act + val actual = repository.getAccountListSync(userWalletId) + + // Assert + val expected = accountList.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createAccountListConverter(userWalletId = userWalletId) + accountListConverter.convert(response) + } + } + + @Test + fun `getAccounts should throw exception if converter throws exception`() = runTest { + // Arrange + val response = mockk() + mockk() + + accountsResponseStoreFlow.value = response + + val exception = Exception("Test error") + + every { accountListConverter.convert(response) } throws exception + + // Act + val actual = runCatching { repository.getAccountListSync(userWalletId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createAccountListConverter(userWalletId = userWalletId) + accountListConverter.convert(response) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountSync { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `getAccount should return None when account response is null`() = runTest { + // Arrange + val response = null + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) } + } + + @Test + fun `getAccount should return None when accountDto is not found`() = runTest { + // Arrange + val response = mockk { + every { this@mockk.accounts } returns emptyList() + } + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) } + } + + @Test + fun `getAccount should return Account_CryptoPortfolio when account response is not null`() = runTest { + // Arrange + val accountDTO = mockk { + every { this@mockk.id } returns accountId.value + } + + val response = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + accountsResponseStoreFlow.value = response + + val cryptoPortfolio = mockk() + + every { cryptoPortfolioConverter.convert(accountDTO) } returns cryptoPortfolio + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + val expected = cryptoPortfolio.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createCryptoPortfolioConverter(userWalletId) + cryptoPortfolioConverter.convert(accountDTO) + } + } + + @Test + fun `getAccount should throw exception if converter throws exception`() = runTest { + // Arrange + val accountDTO = mockk { + every { this@mockk.id } returns accountId.value + } + + val response = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + accountsResponseStoreFlow.value = response + + val exception = Exception("Test error") + + every { cryptoPortfolioConverter.convert(accountDTO) } throws exception + + // Act + val actual = runCatching { repository.getAccountSync(accountId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createCryptoPortfolioConverter(userWalletId) + cryptoPortfolioConverter.convert(accountDTO) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccountSync { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `getArchivedAccount should return None when archived accounts are null`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should return None when archived account not found`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = listOf()) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should return ArchivedAccount when found`() = runTest { + // Arrange + val archivedAccount = ArchivedAccount( + accountId = accountId, + name = AccountName("Archived Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = DerivationIndex.Main, + tokensCount = 0, + networksCount = 0, + ) + + archivedAccountsInnerStore.store(value = listOf(archivedAccount)) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + val expected = archivedAccount.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should throws exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccountSync(accountId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccountListSync { + + @Test + fun `getArchivedAccountListSync should return None when archived accounts are null`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + // Act + val actual = repository.getArchivedAccountListSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccountListSync should return Option with list when archived accounts exist`() = runTest { + // Arrange + val archivedAccount1 = mockk() + val archivedAccount2 = mockk() + val archivedAccounts = listOf(archivedAccount1, archivedAccount2) + archivedAccountsInnerStore.store(value = archivedAccounts) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountListSync(userWalletId) + + // Assert + val expected = archivedAccounts.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccountListSync should throw exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccountListSync(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccounts { + + @Test + fun `getArchivedAccounts should emit empty list when no archived accounts`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId) + + // Act + val actual = getEmittedValues(archivedAccountsFlow) + + // Assert + Truth.assertThat(actual).isEmpty() + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.get() + } + } + + @Test + fun `getArchivedAccounts should emit list of archived accounts when present`() = runTest { + // Arrange + val archivedAccount1 = mockk() + val archivedAccount2 = mockk() + val archivedAccounts = listOf(archivedAccount1, archivedAccount2) + + archivedAccountsInnerStore.store(value = archivedAccounts) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId) + + // Act + val actual = getEmittedValues(archivedAccountsFlow) + + // Assert + val expected = listOf(archivedAccounts) + Truth.assertThat(actual).containsExactlyElementsIn(expected) + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.get() + } + } + + @Test + fun `getArchivedAccounts should throw exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccounts(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FetchArchivedAccounts { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `fetchArchivedAccounts should store archived accounts in store`() = runTest { + // Arrange + val accountDTO = WalletAccountDTO( + id = accountId.value, + name = "Archived Account", + derivationIndex = 0, + icon = CryptoPortfolioIcon.Icon.Wallet.name, + iconColor = CryptoPortfolioIcon.Color.DullLavender.name, + totalNetworks = 0, + totalTokens = 0, + ) + + val apiResponse = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO) + + coEvery { + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) + } returns ApiResponse.Success(apiResponse) + + // Act + repository.fetchArchivedAccounts(userWalletId) + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).containsExactly(archivedAccount) + + coVerifyOrder { + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) + archivedAccountsStoreFactory.create(userWalletId) + } + } + + @Test + fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { // Arrange + val exception = Exception("API error") + coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } throws exception + + // Act + val actual = runCatching { repository.fetchArchivedAccounts(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull() + + coVerify { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SaveAccounts { + + private val version = 1 + + @Test + fun `saveAccounts should call API and update store`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + val accountList = AccountList.empty(userWallet = userWallet) + + val accountsResponse = mockk { + every { this@mockk.wallet.version } returns version + } + + accountsResponseStoreFlow.value = accountsResponse + + val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + + val apiResponse = ApiResponse.Success(Unit) + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + ifMatch = version.toString(), + body = body, + ) + } returns apiResponse + + val converter = mockk { + every { this@mockk.convert(accountList) } returns accountsResponse + } + + every { + convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet, version = version) + } returns converter + + coEvery { accountsResponseStore.updateData(transform = any()) } returns accountsResponse + + // Act + repository.saveAccounts(accountList) + + // Assert + Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse) + + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body) + convertersContainer.getWalletAccountsResponseCF.create(userWallet, version) + converter.convert(accountList) + accountsResponseStore.updateData(any()) + } + } + + @Test + fun `saveAccounts if API request is failed`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + val accountList = AccountList.empty(userWallet = userWallet) + + val accountsResponse = mockk { + every { this@mockk.wallet.version } returns version + } + + accountsResponseStoreFlow.value = accountsResponse + + val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + + val apiResponse = ApiResponse.Error(cause = ApiResponseError.NetworkException) as ApiResponse + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + ifMatch = version.toString(), + body = body, + ) + } returns apiResponse + + // Act + val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isEqualTo(ApiResponseError.NetworkException) + + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body) + } + + coVerify(inverse = true) { + convertersContainer.getWalletAccountsResponseCF.create(any(), any()) + accountsResponseStore.updateData(any()) + } + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt index 977c3c4169..a7358ba43d 100644 --- a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class ArchivedAccountsStoreFactoryTest { - private val factory = ArchivedAccountsStoreFactory() + private val factory = ArchivedAccountsStoreFactory @AfterEach fun tearDownEach() { diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt index ac6921e167..8904840bcb 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt @@ -22,7 +22,7 @@ interface AccountsCRUDRepository { * @param userWalletId the unique identifier of the user wallet * @return an [Option] containing the [AccountList] if found, or `Option.None` if not */ - suspend fun getAccounts(userWalletId: UserWalletId): Option + suspend fun getAccountListSync(userWalletId: UserWalletId): Option /** * Retrieves a specific account by its unique identifier @@ -30,14 +30,14 @@ interface AccountsCRUDRepository { * @param accountId the unique identifier of the account * @return an [Option] containing the [Account.CryptoPortfolio] if found, or `Option.None` if not */ - suspend fun getAccount(accountId: AccountId): Option + suspend fun getAccountSync(accountId: AccountId): Option /** * Retrieves a archived account by its unique identifier * * @param accountId the unique identifier of the account */ - suspend fun getArchivedAccount(accountId: AccountId): Option + suspend fun getArchivedAccountSync(accountId: AccountId): Option /** * Retrieves a list of archived accounts associated with a specific user wallet @@ -45,7 +45,7 @@ interface AccountsCRUDRepository { * @param userWalletId the unique identifier of the user wallet * @return an [Option] containing a list of [ArchivedAccount] if found, or `Option.None` if not */ - suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option> + suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option> /** * Provides a flow of archived accounts associated with a specific user wallet @@ -73,7 +73,7 @@ interface AccountsCRUDRepository { * * @param userWalletId the unique identifier of the user wallet */ - suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int + suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option /** * Retrieves a user wallet by its unique identifier diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt index 6a3866bc4b..6f233d2da7 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt @@ -55,7 +55,7 @@ class AddCryptoPortfolioUseCase( newAccount } - private fun Raise.createAccount( + private fun createAccount( userWalletId: UserWalletId, accountName: AccountName, icon: CryptoPortfolioIcon, @@ -72,7 +72,7 @@ class AddCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): Option { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt index 0611b106fb..75056d6eb0 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt @@ -40,7 +40,7 @@ class ArchiveCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt index cbcfb13168..afaa2a587e 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt @@ -57,7 +57,7 @@ class GetArchivedAccountsUseCase( private suspend fun getArchivedAccounts(userWalletId: UserWalletId): Either { return Either.catch { - crudRepository.getArchivedAccountsSync(userWalletId = userWalletId).getOrElse { + crudRepository.getArchivedAccountListSync(userWalletId = userWalletId).getOrElse { error("Archived accounts not found for user wallet: $userWalletId") } } @@ -70,7 +70,11 @@ class GetArchivedAccountsUseCase( private suspend fun ProducerScope>.subscribeOnArchivedAccounts( userWalletId: UserWalletId, ) { - crudRepository.getArchivedAccounts(userWalletId) + runCatching { crudRepository.getArchivedAccounts(userWalletId) } + .getOrElse { + send(it.lceError()) + return + } .distinctUntilChanged() .retryWhen { cause, _ -> send(cause.lceError()) diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt index c34240e22b..f511ab03b8 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt @@ -38,6 +38,7 @@ class GetUnoccupiedAccountIndexUseCase( block = { crudRepository.getTotalAccountsCount(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) + .getOrElse { raise(Error.DataNotFound) } } /** @@ -48,6 +49,10 @@ class GetUnoccupiedAccountIndexUseCase( val tag: String get() = this::class.simpleName ?: "GetUnoccupiedAccountIndexUseCase.Error" + data object DataNotFound : Error { + override fun toString(): String = "$tag: Data not found" + } + /** Error indicating that the derivation index is invalid */ data class InvalidDerivationIndex(val cause: DerivationIndex.Error) : Error { override fun toString(): String = "$tag: Invalid derivation index: $cause" diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt index f5dcec41aa..1bff615daf 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt @@ -44,7 +44,7 @@ class RecoverCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } @@ -52,7 +52,7 @@ class RecoverCryptoPortfolioUseCase( private suspend fun Raise.getArchivedAccount(accountId: AccountId): ArchivedAccount { return catch( - block = { crudRepository.getArchivedAccount(accountId = accountId) }, + block = { crudRepository.getArchivedAccountSync(accountId = accountId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt index 4451a5f50d..2145e7ed15 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt @@ -61,7 +61,7 @@ class UpdateCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt index f30bc1dfda..f677187f17 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt @@ -41,7 +41,7 @@ class AddCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet) val updatedAccountList = (accountList + newAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase( @@ -56,7 +56,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } @@ -69,7 +69,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount() val newAccountList = (AccountList.empty(userWallet) + newAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None coEvery { crudRepository.getUserWallet(userWalletId) } returns userWallet // Act @@ -85,7 +85,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.getUserWallet(userWalletId) crudRepository.saveAccounts(newAccountList) } @@ -102,7 +102,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount(derivationIndex = 21) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase( @@ -119,7 +119,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getUserWallet(any()) @@ -133,7 +133,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount() val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase( @@ -147,7 +147,7 @@ class AddCryptoPortfolioUseCaseTest { val expected = AddCryptoPortfolioUseCase.Error.DataOperationFailed(cause = exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getUserWallet(any()) @@ -164,7 +164,7 @@ class AddCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -180,7 +180,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt index 1e442938fa..fe67edc618 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt @@ -41,7 +41,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList - account).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId) @@ -51,7 +51,7 @@ class ArchiveCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } } @@ -64,7 +64,7 @@ class ArchiveCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex.Main, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act val actual = useCase(accountId) @@ -73,7 +73,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -87,7 +87,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase(accountId) @@ -96,7 +96,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -109,7 +109,7 @@ class ArchiveCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex(1).getOrNull()!!, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId) @@ -118,7 +118,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountNotFound(accountId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -133,7 +133,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -144,7 +144,7 @@ class ArchiveCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt index eb0019f93c..5fd7b2ccce 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt @@ -43,7 +43,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns archivedAccounts.toOption() + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns archivedAccounts.toOption() every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -54,7 +54,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -69,7 +69,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns None + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -83,7 +83,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -98,7 +98,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } throws exception + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } throws exception every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -112,7 +112,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -123,7 +123,7 @@ class GetArchivedAccountsUseCaseTest { // Arrange val exception = IllegalStateException("Fetch error") - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns None + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None every { crudRepository.getArchivedAccounts(userWalletId) } returns emptyFlow() coEvery { crudRepository.fetchArchivedAccounts(userWalletId) } throws exception @@ -139,7 +139,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt index 4c994aeb21..a0ba98b709 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt @@ -1,6 +1,7 @@ package com.tangem.domain.account.usecase import arrow.core.left +import arrow.core.toOption import com.google.common.truth.Truth import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.account.DerivationIndex @@ -29,7 +30,7 @@ class GetUnoccupiedAccountIndexUseCaseTest { @Test fun `invoke should return next unoccupied index when repository returns count`() = runTest { // Arrange - coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3 + coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3.toOption() // Act val actual = useCase(userWalletId = userWalletId) diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt index 7c8f1a847f..eb4e423c11 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt @@ -52,8 +52,8 @@ class RecoverCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList + account).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() // Act val actual = useCase(account.accountId) @@ -63,8 +63,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) } } @@ -77,7 +77,7 @@ class RecoverCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex.Main, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act val actual = useCase(accountId) @@ -86,9 +86,9 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { - crudRepository.getArchivedAccount(any()) + crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) } } @@ -102,7 +102,7 @@ class RecoverCryptoPortfolioUseCaseTest { ) val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase(accountId) @@ -111,9 +111,9 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { - crudRepository.getArchivedAccount(any()) + crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) } } @@ -125,8 +125,8 @@ class RecoverCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet) val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } throws exception // Act val actual = useCase(account.accountId) @@ -136,8 +136,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -148,8 +148,8 @@ class RecoverCryptoPortfolioUseCaseTest { val account = createAccount(userWalletId) val accountList = AccountList.empty(userWallet) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None // Act val actual = useCase(account.accountId) @@ -159,8 +159,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -182,8 +182,8 @@ class RecoverCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList + account).getOrNull()!! val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -194,8 +194,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt index f1ba896a7c..5d6625fbe0 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt @@ -48,7 +48,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -58,7 +58,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -76,7 +76,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, icon = newAccountIcon) @@ -86,7 +86,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -105,7 +105,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName, icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName, icon = newAccountIcon) @@ -115,7 +115,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -126,7 +126,7 @@ class UpdateCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet = userWallet) val accountId = accountList.mainAccount.accountId - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId) @@ -136,7 +136,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(inverse = true) { - crudRepository.getAccounts(userWalletId = any()) + crudRepository.getAccountListSync(userWalletId = any()) crudRepository.saveAccounts(accountList = any()) } } @@ -151,7 +151,7 @@ class UpdateCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test exception") - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } throws exception // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -160,7 +160,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(cause = exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -175,7 +175,7 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -184,7 +184,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -199,7 +199,7 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -208,7 +208,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountNotFound(accountId = accountId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -224,7 +224,7 @@ class UpdateCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(accountList = updatedAccountList) } throws exception // Act @@ -235,7 +235,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } From 31558ee15d409ce1b242f67ded73cf2353a95882 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 11:32:46 +0000 Subject: [PATCH 022/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 57e50cf4ac..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.27.0-1148" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.27.0-519" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-454" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 2836d7bb2bc1a369fc8c9816d74d869e3a7a9a39 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 14:38:10 +0300 Subject: [PATCH 023/132] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 16 ++++++++++++++++ .../feature/impl/DevFeatureTogglesManager.kt | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 823d7df27c..7fd98efe81 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -14,6 +14,8 @@ import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase import com.tangem.common.allure.FailedStepScreenshotInterceptor import com.tangem.common.rules.ApiEnvironmentRule +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -46,6 +48,9 @@ abstract class BaseTestCase : TestCase( @Inject lateinit var appPreferencesStore: AppPreferencesStore + @Inject + lateinit var featureTogglesManager: FeatureTogglesManager + private val hiltRule = HiltAndroidRule(this) private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( @@ -90,6 +95,7 @@ abstract class BaseTestCase : TestCase( apiEnvironmentRule.setup(apiConfigsManager) ActivityScenario.launch(MainActivity::class.java) Intents.init() + setFeatureToggles() additionalBeforeSection() }.after { additionalAfterSection() @@ -113,4 +119,14 @@ abstract class BaseTestCase : TestCase( { composeTestRule.onRoot(useUnmergedTree = useUnmergedTree).printToLog(tag, maxDepth) } + + + private fun setFeatureToggles() { + runBlocking { + with(featureTogglesManager as MutableFeatureTogglesManager) { + changeToggle("WALLET_CONNECT_REDESIGN_ENABLED", true) + changeToggle("WALLET_BALANCE_FETCHER_ENABLED", true) + } + } + } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index 6facf5dc8b..6677cae6d0 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -11,7 +11,7 @@ import java.util.Locale import kotlin.properties.Delegates /** - * Feature toggles manager implementation in DEV build + * Feature toggles manager implementation in dev or mocked build * * @property versionProvider application version provider * @property featureTogglesLocalStorage local storage for feature toggles From bc7d7ea7b6fdb80a6f8d400b766b7c3585fc0df2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 15:41:41 +0200 Subject: [PATCH 024/132] Updated on 2026-08-14 --- .gitignore | 4 + app/build.gradle.kts | 33 ++++++ app/proguard-rules.pro | 7 ++ .../FirebasePushNotificationsTokenProvider.kt | 2 +- .../com/tangem/tap/di/GooglePushModule.kt} | 6 +- app/src/huawei/AndroidManifest.xml | 17 +++ .../HuaweiPushNotificationsTokenProvider.kt | 50 +++++++++ .../java/com/tangem/tap/HuaweiPushService.kt | 47 ++++++++ .../com/tangem/tap/di/HuaweiPushModule.kt | 18 ++++ .../AppConfigurationProviderImpl.kt | 13 +++ .../common/pushes/PushNotificationDelegate.kt | 102 ++++++++++++++++++ .../pushes/TangemPushNotificationService.kt | 92 +++------------- .../tangem/tap/di/AppConfigurationModule.kt | 18 ++++ build.gradle.kts | 7 ++ .../buildConfig/AppConfigurationProvider.kt | 6 ++ gradle/dependencies.toml | 16 +++ .../extension/AppExtensionConfigurations.kt | 1 + settings.gradle.kts | 2 + 18 files changed, 357 insertions(+), 84 deletions(-) rename app/src/{main/java/com/tangem/tap/data => google/java/com/tangem/tap}/FirebasePushNotificationsTokenProvider.kt (95%) rename app/src/{main/java/com/tangem/tap/di/data/PushNotificationsModule.kt => google/java/com/tangem/tap/di/GooglePushModule.kt} (74%) create mode 100644 app/src/huawei/AndroidManifest.xml create mode 100644 app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt create mode 100644 app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt create mode 100644 app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt create mode 100644 app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt create mode 100644 app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt create mode 100644 app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt create mode 100644 core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt diff --git a/.gitignore b/.gitignore index 6106311a37..0144ddc6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ local.properties # Google services + +# Huawei services +app/agconnect-services.json + app/src/debug/google-services.json app/src/internal/google-services.json app/src/external/google-services.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17ee83e2e2..720052668f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,9 +11,14 @@ plugins { alias(deps.plugins.firebase.crashlytics) alias(deps.plugins.firebase.perf) alias(deps.plugins.ksp) + id(deps.plugins.agconnect.get().pluginId) id("configuration") } +agcp { + manifest = false +} + android { namespace = "com.tangem.wallet" testOptions { @@ -53,12 +58,35 @@ android { keyPassword = keystoreProperties["key_password"] as String } } + + flavorDimensions += "services" + + productFlavors { + create("google") { + dimension = "services" + buildConfigField("String", "FLAVOR_NAME", "\"google\"") + } + create("huawei") { + dimension = "services" + buildConfigField("String", "FLAVOR_NAME", "\"huawei\"") + } + } + + buildTypes { + debug { + buildConfigField("String", "BUILD_TYPE", "\"debug\"") + } + release { + buildConfigField("String", "BUILD_TYPE", "\"release\"") + } + } } configurations.all { exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18") exclude(group = "com.github.komputing.kethereum") + exclude(group = "com.android.tools.build", module = "gradle") resolutionStrategy { dependencySubstitution { @@ -379,4 +407,9 @@ dependencies { // excludes version 9999.0-empty-to-avoid-conflict-with-guava exclude(group = "com.google.guava", module = "listenablefuture") } + + /** Huawei flavor-specific dependencies */ + "huaweiImplementation"(deps.huawei.push) + "huaweiImplementation"(deps.agconnect.agcp) + "huaweiImplementation"(deps.agconnect.core) } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2a7b205c9d..4e36894e88 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,6 +7,13 @@ -keep class com.google.android.gms.internal.** { *; } -keepclasseswithmembers class com.google.firebase.FirebaseException +# huawei push kit +-ignorewarnings +-keepattributes SourceFile,LineNumberTable +-keep class com.huawei.hianalytics.**{*;} +-keep class com.huawei.updatesdk.**{*;} +-keep class com.huawei.hms.**{*;} + # hedera sdk -keep class com.hedera.hashgraph.sdk.** { *; } -keep interface com.hedera.hashgraph.sdk.** { *; } diff --git a/app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt b/app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt similarity index 95% rename from app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt rename to app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt index 358bdd3d61..627e41f2cc 100644 --- a/app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt +++ b/app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.data +package com.tangem.tap import com.google.firebase.messaging.FirebaseMessaging import com.tangem.utils.notifications.PushNotificationsTokenProvider diff --git a/app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt b/app/src/google/java/com/tangem/tap/di/GooglePushModule.kt similarity index 74% rename from app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt rename to app/src/google/java/com/tangem/tap/di/GooglePushModule.kt index 063efaf3dc..a8e4e87c08 100644 --- a/app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt +++ b/app/src/google/java/com/tangem/tap/di/GooglePushModule.kt @@ -1,6 +1,6 @@ -package com.tangem.tap.di.data +package com.tangem.tap.di -import com.tangem.tap.data.FirebasePushNotificationsTokenProvider +import com.tangem.tap.FirebasePushNotificationsTokenProvider import com.tangem.utils.notifications.PushNotificationsTokenProvider import dagger.Binds import dagger.Module @@ -10,7 +10,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal interface PushNotificationsModule { +internal interface GooglePushModule { @Binds @Singleton diff --git a/app/src/huawei/AndroidManifest.xml b/app/src/huawei/AndroidManifest.xml new file mode 100644 index 0000000000..0c769dbe3e --- /dev/null +++ b/app/src/huawei/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt b/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt new file mode 100644 index 0000000000..03c709cd6c --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt @@ -0,0 +1,50 @@ +package com.tangem.tap + +import android.content.Context +import com.google.firebase.messaging.FirebaseMessaging +import com.huawei.agconnect.AGConnectOptionsBuilder +import com.huawei.hms.aaid.HmsInstanceId +import com.huawei.hms.common.ApiException +import com.tangem.google.GoogleServicesHelper +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.notifications.PushNotificationsTokenProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +internal class HuaweiPushNotificationsTokenProvider @Inject constructor( + @ApplicationContext private val context: Context, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, +) : PushNotificationsTokenProvider { + + override suspend fun getToken(): String { + val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(context) + return if (isGoogleServicesAvailable) { + try { + FirebaseMessaging.getInstance().token.await() + } catch (ex: Exception) { + Timber.e(ex) + "" + } + } else { + withContext(coroutineDispatcherProvider.io) { + try { + val appId = AGConnectOptionsBuilder().build(context).getString(APP_ID_KEY) + val token = HmsInstanceId.getInstance(context).getToken(appId, TOKEN_REQUEST_MODE) + Timber.i("Requested token from HuaweiService: $token") + token + } catch (e: ApiException) { + Timber.i("Fetching token from HuaweiService failed cause: ${e.message}") + "" + } + } + } + } + + companion object { + private const val APP_ID_KEY = "client/app_id" + private const val TOKEN_REQUEST_MODE = "HCM" + } +} \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt new file mode 100644 index 0000000000..b7a1de528d --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt @@ -0,0 +1,47 @@ +package com.tangem.tap + +import android.os.Bundle +import com.huawei.hms.push.HmsMessageService +import com.huawei.hms.push.RemoteMessage +import com.tangem.google.GoogleServicesHelper +import com.tangem.tap.common.pushes.PushNotificationDelegate +import timber.log.Timber + +class HuaweiPushService : HmsMessageService() { + + private val pushNotificationDelegate: PushNotificationDelegate by lazy { + PushNotificationDelegate(applicationContext) + } + + override fun onNewToken(token: String?, bundle: Bundle?) { + super.onNewToken(token, bundle) + Timber.i("HuaweiPushService: On new token from HuaweiService: $token") + } + + override fun onTokenError(e: Exception?, bundle: Bundle?) { + super.onTokenError(e, bundle) + Timber.i("HuaweiPushService: Fetching token from HuaweiService failed cause: ${e?.message}") + } + + override fun onMessageReceived(message: RemoteMessage?) { + super.onMessageReceived(message) + val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(this) + if (isGoogleServicesAvailable) return + val notification = message?.notification ?: return + val channelId = notification.channelId ?: TANGEM_CHANNEL_ID + + pushNotificationDelegate.showNotification( + dataMap = message.dataOfMap, + title = notification.title, + body = notification.body, + channelId = channelId, + priority = message.urgency, + imageUrl = notification.imageUrl, + vibratePattern = notification.vibrateConfig, + ) + } + + private companion object { + const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications + } +} \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt b/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt new file mode 100644 index 0000000000..f4379c5469 --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di + +import com.tangem.tap.HuaweiPushNotificationsTokenProvider +import com.tangem.utils.notifications.PushNotificationsTokenProvider +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 HuaweiPushModule { + + @Binds + @Singleton + fun bindPushNotificationsTokenProvider(impl: HuaweiPushNotificationsTokenProvider): PushNotificationsTokenProvider +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt b/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt new file mode 100644 index 0000000000..7a87d69e7c --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt @@ -0,0 +1,13 @@ +package com.tangem.tap.common.buildconfig + +import com.tangem.utils.buildConfig.AppConfigurationProvider +import com.tangem.wallet.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class AppConfigurationProviderImpl @Inject constructor() : AppConfigurationProvider { + + override fun isDebug(): Boolean = BuildConfig.BUILD_TYPE == "debug" + override fun isHuawei(): Boolean = BuildConfig.FLAVOR_NAME == "huawei" +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt new file mode 100644 index 0000000000..844eec86ff --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt @@ -0,0 +1,102 @@ +package com.tangem.tap.common.pushes + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import coil.executeBlocking +import coil.request.ImageRequest +import com.tangem.domain.common.LogConfig +import com.tangem.tap.MainActivity +import com.tangem.tap.common.images.createCoilImageLoader +import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler +import com.tangem.wallet.R + +class PushNotificationDelegate(private val context: Context) { + + @Suppress("LongParameterList") + fun showNotification( + dataMap: Map, + title: String?, + body: String?, + channelId: String, + priority: Int, + imageUrl: Uri? = null, + vibratePattern: LongArray?, + ) { + val intent = Intent(context, MainActivity::class.java).apply { + dataMap.forEach { (key, value) -> + putExtra(key, value) + } + putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + val pendingIntent = PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_tangem_24) + .setContentTitle(title) + .setContentText(body) + .setPriority(priority) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setVibrate(vibratePattern) + .apply { + imageUrl?.let { uri -> + val bitmap = getBitmapImageFromUrl(uri) + setStyle( + NotificationCompat + .BigPictureStyle() + .bigPicture(bitmap), + ).setLargeIcon(bitmap) + } + } + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( + channelId, + ContextCompat.getString(context, R.string.tangem_app_name), + NotificationManager.IMPORTANCE_HIGH, + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + // Generating unique notification id + val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt() + + notificationManager.notify( + /* id = */ uniqueId, + /* notification = */ notificationBuilder.build(), + ) + } + + private fun getBitmapImageFromUrl(url: Uri): Bitmap? { + return createCoilImageLoader( + context, + logEnabled = LogConfig.imageLoader, + ).executeBlocking( + ImageRequest.Builder(context) + .data(url) + .build(), + ).drawable?.toBitmap() + } + + private companion object { + const val PUSH_NOTIFICATION_REQUEST_CODE = 123 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt index ebeec6dffc..97026725ea 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt @@ -1,30 +1,17 @@ package com.tangem.tap.common.pushes import android.annotation.SuppressLint -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent -import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toBitmap -import coil.executeBlocking -import coil.request.ImageRequest import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import com.tangem.domain.common.LogConfig -import com.tangem.tap.MainActivity -import com.tangem.tap.common.images.createCoilImageLoader -import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler -import com.tangem.wallet.R import timber.log.Timber @SuppressLint("MissingFirebaseInstanceTokenRefresh") internal class TangemPushNotificationService : FirebaseMessagingService() { + private val pushNotificationDelegate: PushNotificationDelegate by lazy { + PushNotificationDelegate(applicationContext) + } + override fun onNewToken(token: String) { super.onNewToken(token) Timber.d("New FCM token received: $token") @@ -36,73 +23,18 @@ internal class TangemPushNotificationService : FirebaseMessagingService() { val notification = message.notification ?: return val channelId = notification.channelId ?: TANGEM_CHANNEL_ID - val intent = Intent(applicationContext, MainActivity::class.java).apply { - message.data.forEach { - putExtra(it.key, it.value) - } - putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - val pendingIntent = PendingIntent.getActivity( - /* context = */ this, - /* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE, - /* intent = */ intent, - /* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + pushNotificationDelegate.showNotification( + dataMap = message.data, + title = notification.title, + body = notification.body, + channelId = channelId, + priority = message.priority, + imageUrl = notification.imageUrl, + vibratePattern = notification.vibrateTimings, ) - - val notificationBuilder = - NotificationCompat.Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.ic_tangem_24) - .setContentTitle(notification.title) - .setContentText(notification.body) - .setPriority(message.priority) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setVibrate(notification.vibrateTimings) - .apply { - notification.imageUrl?.let { uri -> - val bitmap = getBitmapImageFromUrl(uri) - setStyle( - NotificationCompat - .BigPictureStyle() - .bigPicture(bitmap), - ).setLargeIcon(bitmap) - } - } - - val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationChannel = NotificationChannel( - channelId, - ContextCompat.getString(applicationContext, R.string.tangem_app_name), - NotificationManager.IMPORTANCE_HIGH, - ) - notificationManager.createNotificationChannel(notificationChannel) - } - - // Generating unique notification id - val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt() - - notificationManager.notify( - /* id = */ uniqueId, - /* notification = */ notificationBuilder.build(), - ) - } - - private fun getBitmapImageFromUrl(url: Uri): Bitmap? { - return createCoilImageLoader( - applicationContext, - logEnabled = LogConfig.imageLoader, - ).executeBlocking( - ImageRequest.Builder(applicationContext) - .data(url) - .build(), - ).drawable?.toBitmap() } private companion object { const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications - const val PUSH_NOTIFICATION_REQUEST_CODE = 123 } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt b/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt new file mode 100644 index 0000000000..42148c10d5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di + +import com.tangem.tap.common.buildconfig.AppConfigurationProviderImpl +import com.tangem.utils.buildConfig.AppConfigurationProvider +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 AppConfigurationModule { + + @Binds + @Singleton + fun bindAppConfigurationProvider(impl: AppConfigurationProviderImpl): AppConfigurationProvider +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c490aef8c2..5d73b767fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,13 @@ plugins { alias(deps.plugins.ksp) apply false } +buildscript { + dependencies { + classpath(deps.gradle.android) + classpath(deps.agconnect.agcp) + } +} + val clean by tasks.registering { delete(rootProject.buildDir) } diff --git a/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt b/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt new file mode 100644 index 0000000000..863864842e --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt @@ -0,0 +1,6 @@ +package com.tangem.utils.buildConfig + +interface AppConfigurationProvider { + fun isDebug(): Boolean + fun isHuawei(): Boolean +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index f9ef6078d0..37e38fce69 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -11,6 +11,12 @@ ksp = "2.1.10-1.0.30" firebasePerf = "1.4.2" # endregion Classpath +# region AppGallery +agconnect = "1.9.1.304" +huaweiServices = "6.9.0.301" +huaweiPush = "6.11.0.300" +# endregion AppGallery + # region AndroidX androidxActivityCompose = "1.8.0" androidxAppCompat = "1.5.1" @@ -130,6 +136,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } room = { id = "androidx.room", version.ref = "room" } kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +agconnect = { id = "com.huawei.agconnect", version.ref = "agconnect" } [libraries] # region Classpath @@ -190,6 +197,15 @@ firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } firebase-perf = { module = "com.google.firebase:firebase-perf" } # endregion Firebase +# region AppGallery +agconnect-agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agconnect" } +agconnect-core = { module = "com.huawei.agconnect:agconnect-core", version.ref = "agconnect" } +agconnect-crash = { module = "com.huawei.agconnect:agconnect-crash", version.ref = "agconnect" } +huawei-base = { module = "com.huawei.hms:base", version.ref = "huaweiServices" } +huawei-analytics = { module = "com.huawei.hms:hianalytics", version.ref = "huaweiServices" } +huawei-push = { module = "com.huawei.hms:push", version.ref = "huaweiPush" } +# endregion AppGallery + # region Detekt detekt-compose = { module = "ru.kode:detekt-rules-compose", version.ref = "detektComposeRules" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt index 6f03ce1a3a..88e9d25480 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt @@ -75,6 +75,7 @@ private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, b } BuildType.Debug -> { isDebuggable = true + signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) } BuildType.Internal, BuildType.External diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c7251cb46..1511ce8a21 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ pluginManagement { gradlePluginPortal() google() mavenCentral() + maven { url = uri("https://developer.huawei.com/repo/") } } includeBuild("plugins/configuration") @@ -35,6 +36,7 @@ dependencyResolutionManagement { } } mavenCentral() + maven { url = uri("https://developer.huawei.com/repo/") } mavenLocal { content { includeGroupAndSubgroups("com.tangem.tangem-sdk-kotlin") From 4c2ce375e919a26b942a0a46d47fc6ac7eed1967 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 08:53:06 +0200 Subject: [PATCH 025/132] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 ++++ .../appbar/models/TopAppBarButtonUM.kt | 6 ++++++ .../onramp/main/entity/OnrampIntents.kt | 1 + .../main/model/OnrampMainComponentModel.kt | 2 ++ .../DefaultOnrampNewMainFeatureToggle.kt | 10 +++++++++ .../onramp/newmain/OnrampNewMainComponent.kt | 21 +++++++++++++++++++ .../newmain/OnrampNewMainFeatureToggle.kt | 5 +++++ 7 files changed, 49 insertions(+) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt 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 318f39ce65..9d99dceed5 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 @@ -66,5 +66,9 @@ { "name": "YIELD_LENDING_FEATURE_ENABLED", "version": "undefined" + }, + { + "name": "NEW_ONRAMP_MAIN_ENABLED", + "version": "undefined" } ] diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt index aa06b614a0..3d1549e138 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt @@ -32,6 +32,12 @@ sealed class TopAppBarButtonUM( enabled = enabled, ) + fun Close(enabled: Boolean = true, onCloseClick: () -> Unit) = Icon( + iconRes = R.drawable.ic_close_24, + onClicked = onCloseClick, + enabled = enabled, + ) + fun Text(text: TextReference, onTextClicked: () -> Unit, enabled: Boolean = true) = Text( text = text, onClicked = onTextClicked, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt index 5654a17174..6ed094c0cb 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt @@ -10,4 +10,5 @@ interface OnrampIntents { fun openProviders() fun onRefresh() fun onLinkClick(link: String) + fun onContinueClick() } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 97f9d147c7..50acb8a5a6 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -281,6 +281,8 @@ internal class OnrampMainComponentModel @Inject constructor( override fun onLinkClick(link: String) = urlOpener.openUrl(link) + override fun onContinueClick() = Unit + override fun onDestroy() { modelScope.launch { clearOnrampCacheUseCase.invoke() } quotesTaskScheduler.cancelTask() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt new file mode 100644 index 0000000000..e73a8570bd --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt @@ -0,0 +1,10 @@ +package com.tangem.features.onramp.newmain + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager + +class DefaultOnrampNewMainFeatureToggle( + private val featureTogglesManager: FeatureTogglesManager, +) : OnrampNewMainFeatureToggle { + override val isOnrampNewMainEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_RECEIVE_ENABLED") +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt new file mode 100644 index 0000000000..d30d134f81 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.onramp.newmain + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.domain.onramp.model.OnrampSource + +internal interface OnrampNewMainComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val source: OnrampSource, + val openSettings: () -> Unit, + val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt new file mode 100644 index 0000000000..5eab7b94c5 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt @@ -0,0 +1,5 @@ +package com.tangem.features.onramp.newmain + +internal interface OnrampNewMainFeatureToggle { + val isOnrampNewMainEnabled: Boolean +} \ No newline at end of file From 2cd8786c752059177b1a178ac2d4220ca6bd39d2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 13:30:29 +0500 Subject: [PATCH 026/132] Updated on 2026-08-14 --- .../grid/EnumeratedTwoColumnGrid.kt | 104 ++++++++++++++++++ .../entity/EnumeratedTwoColumnGridItem.kt | 6 + .../phrase/entity/ManualBackupPhraseUM.kt | 10 +- .../phrase/model/ManualBackupPhraseModel.kt | 3 +- .../phrase/ui/ManualBackupPhraseContent.kt | 76 +------------ .../GenerateSeedPhraseUiStateBuilder.kt | 9 +- .../ui/MultiWalletSeedPhraseWords.kt | 74 +------------ .../ui/state/MultiWalletSeedPhraseUM.kt | 10 +- 8 files changed, 133 insertions(+), 159 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt b/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt new file mode 100644 index 0000000000..f3c88b8cde --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt @@ -0,0 +1,104 @@ +package com.tangem.core.ui.components.grid + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * EnumeratedTwoColumnGrid component + * + * @param items component items + * @param modifier composable modifier + * + */ +@Composable +fun EnumeratedTwoColumnGrid(items: ImmutableList, modifier: Modifier = Modifier) { + VerticalGrid( + modifier = modifier, + items = items, + ) { item -> + Row( + modifier = Modifier.padding(all = TangemTheme.dimens.size8), + verticalAlignment = Alignment.CenterVertically, + ) { + if (LocalLayoutDirection.current == LayoutDirection.Ltr) { + Text( + modifier = Modifier.width(TangemTheme.dimens.size40), + text = "${item.index}.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + Text( + text = item.mnemonic, + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + } else { + Text( + text = item.mnemonic, + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier.width(TangemTheme.dimens.size40), + text = "${item.index}.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } + } +} + +@Composable +private inline fun VerticalGrid( + items: ImmutableList, + modifier: Modifier = Modifier, + crossinline content: @Composable (T) -> Unit, +) { + val columnLength = items.size / 2 + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + repeat(2) { index -> + Column { + for (i in 0 until columnLength) { + val item = items[index * columnLength + i] + content(item) + } + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640, showBackground = true) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + EnumeratedTwoColumnGrid( + items = List(24) { + EnumeratedTwoColumnGridItem( + index = it + 1, + mnemonic = "word${it + 1}", + ) + }.toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt new file mode 100644 index 0000000000..770855af97 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.components.grid.entity + +data class EnumeratedTwoColumnGridItem( + val index: Int, + val mnemonic: String, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt index 3a799f4f31..a2b29d0220 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt @@ -1,14 +1,10 @@ package com.tangem.features.hotwallet.manualbackup.phrase.entity +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal data class ManualBackupPhraseUM( val onContinueClick: () -> Unit, - val words: ImmutableList = persistentListOf(), -) { - data class MnemonicGridItem( - val index: Int, - val mnemonic: String, - ) -} \ No newline at end of file + val words: ImmutableList = persistentListOf(), +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt index e9625260ad..0c99cd5606 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt @@ -5,6 +5,7 @@ import arrow.core.getOrElse 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.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent @@ -51,7 +52,7 @@ internal class ManualBackupPhraseModel @Inject constructor( uiState.update { it.copy( words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.mapIndexed { index, s -> - ManualBackupPhraseUM.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList(), ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt index 3bba1be463..86458b8395 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt @@ -7,20 +7,18 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.hotwallet.impl.R import com.tangem.features.hotwallet.manualbackup.phrase.entity.ManualBackupPhraseUM -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @Composable @@ -41,8 +39,8 @@ internal fun ManualBackupPhraseContent(state: ManualBackupPhraseUM, modifier: Mo modifier = Modifier.padding(top = 20.dp), ) - SeedPhraseGridBlock( - mnemonicGridItems = state.words, + EnumeratedTwoColumnGrid( + items = state.words, modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 32.dp), @@ -97,70 +95,6 @@ private fun TitleBlock(state: ManualBackupPhraseUM, modifier: Modifier = Modifie } } -@Composable -private fun SeedPhraseGridBlock( - mnemonicGridItems: ImmutableList, - modifier: Modifier = Modifier, -) { - VerticalGrid( - modifier = modifier, - items = mnemonicGridItems, - ) { item -> - Row( - modifier = Modifier.padding(all = TangemTheme.dimens.size8), - verticalAlignment = Alignment.CenterVertically, - ) { - if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - } else { - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } - } -} - -@Composable -private inline fun VerticalGrid( - items: ImmutableList, - modifier: Modifier = Modifier, - crossinline content: @Composable (T) -> Unit, -) { - val columnLength = items.size / 2 - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(2) { index -> - Column { - for (i in 0 until columnLength) { - val item = items[index * columnLength + i] - content(item) - } - } - } - } -} - @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -170,7 +104,7 @@ private fun Preview() { state = ManualBackupPhraseUM( onContinueClick = {}, words = List(12) { - ManualBackupPhraseUM.MnemonicGridItem( + EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word${it + 1}", ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt index e7c4efd3b2..d5c6e72ba3 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.crypto.bip39.Mnemonic import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM @@ -20,10 +21,10 @@ internal class GenerateSeedPhraseUiStateBuilder( option: GeneratedWordsType, ): MultiWalletSeedPhraseUM.GenerateSeedPhrase { val words12 = generatedWords12.mnemonicComponents.mapIndexed { index, s -> - MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList() val words24 = generatedWords24.mnemonicComponents.mapIndexed { index, s -> - MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList() return MultiWalletSeedPhraseUM.GenerateSeedPhrase( @@ -45,8 +46,8 @@ internal class GenerateSeedPhraseUiStateBuilder( private fun switchType( newType: GeneratedWordsType, - generatedWords12: ImmutableList, - generatedWords24: ImmutableList, + generatedWords12: ImmutableList, + generatedWords24: ImmutableList, ) { updateUiState { uiSt -> changeGeneratedWordsType(newType) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt index 1099d8bcc1..039ddff2e5 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt @@ -5,15 +5,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.pluralStringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -21,8 +20,6 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM -import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -45,8 +42,8 @@ internal fun MultiWalletSeedPhraseWords( TitleBlock(state) - SeedPhraseGridBlock( - mnemonicGridItems = state.words, + EnumeratedTwoColumnGrid( + items = state.words, modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 32.dp), @@ -127,67 +124,6 @@ private fun TitleBlock(state: MultiWalletSeedPhraseUM.GenerateSeedPhrase, modifi } } -@Composable -private fun SeedPhraseGridBlock(mnemonicGridItems: ImmutableList, modifier: Modifier = Modifier) { - VerticalGrid( - modifier = modifier, - items = mnemonicGridItems, - ) { item -> - Row( - modifier = Modifier.padding(all = TangemTheme.dimens.size8), - verticalAlignment = Alignment.CenterVertically, - ) { - if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - } else { - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } - } -} - -@Composable -private inline fun VerticalGrid( - items: ImmutableList, - modifier: Modifier = Modifier, - crossinline content: @Composable (T) -> Unit, -) { - val columnLength = items.size / 2 - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(2) { index -> - Column { - for (i in 0 until columnLength) { - val item = items[index * columnLength + i] - content(item) - } - } - } - } -} - @Preview(showBackground = true, heightDp = 640) @Composable private fun Preview() { @@ -195,7 +131,7 @@ private fun Preview() { MultiWalletSeedPhraseWords( state = MultiWalletSeedPhraseUM.GenerateSeedPhrase( words = List(24) { - MnemonicGridItem( + EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word1", ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt index c167a59481..af9e46b850 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.s import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.TextReference import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType @@ -22,15 +23,10 @@ internal sealed class MultiWalletSeedPhraseUM( data class GenerateSeedPhrase( val option: GeneratedWordsType = GeneratedWordsType.Words12, - val words: ImmutableList = persistentListOf(), + val words: ImmutableList = persistentListOf(), val onOptionChange: (GeneratedWordsType) -> Unit = {}, val onContinueClick: () -> Unit = {}, - ) : MultiWalletSeedPhraseUM(order = 1) { - data class MnemonicGridItem( - val index: Int, - val mnemonic: String, - ) - } + ) : MultiWalletSeedPhraseUM(order = 1) data class GeneratedWordsCheck( val wordFields: ImmutableList = persistentListOf(), From 450db48cedc8fbe93b2ecc3d01b9458df442dc9c Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 11:56:40 +0300 Subject: [PATCH 027/132] Updated on 2026-08-14 --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0dd13a52a2..09eae1b14d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -74,7 +74,7 @@ platform :android do gradle( task: "assemble", - build_type: "Internal", + build_type: "GoogleInternal", properties: { 'versionCode' => ENV['version_code'], 'versionName' => ENV['version_name'], From 7a5996534255b9c33a8daedd4a508ad69dcaa697 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 09:44:54 +0000 Subject: [PATCH 028/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 05f6e8b112..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1211" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From d8a0703e4602bce73e5c0e824964d04b19350ca3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 12:10:34 +0000 Subject: [PATCH 029/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 05f6e8b112..33e667f9d0 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1211" +tangemBlockchainSdk = "develop-1212" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-560" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From e250ecd50824222bbaf6b9198f4e8b80f5c585c0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 16:07:49 +0300 Subject: [PATCH 030/132] Updated on 2026-08-14 --- .../walletconnect/WalletConnectSdkHelper.kt | 22 ++++++++++++++++--- .../walletconnect/WalletConnectMiddleware.kt | 17 +++++++++++--- .../ui/resetcard/model/ResetCardModel.kt | 16 +++++++++----- .../tangem/tap/features/main/MainViewModel.kt | 22 +++++++++++-------- .../CryptoCurrencyConverter.kt | 20 ++++++++++++----- .../tangem/tap/proxy/UserWalletManagerImpl.kt | 8 +++---- .../com/tangem/tap/proxy/di/ProxyModule.kt | 6 ++--- .../feedback/DefaultFeedbackRepository.kt | 4 +++- .../tangem/data/feedback/di/FeedbackModule.kt | 3 +++ .../CreateWalletSelectionModel.kt | 8 +++---- .../features/home/impl/model/HomeModel.kt | 16 ++++++++++++-- .../start/AddExistingWalletStartModel.kt | 8 +++---- .../entry/impl/model/OnboardingEntryModel.kt | 17 ++++++++++++++ 13 files changed, 123 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt index 9df63ef08a..e6e92ccda4 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt @@ -47,6 +47,14 @@ class WalletConnectSdkHelper { store.inject(DaggerGraphState::generalUserWalletsListManager) } + private val userWalletsListRepository by lazy { + store.inject(DaggerGraphState::userWalletsListRepository) + } + + private val hotWalletFeatureToggles by lazy { + store.inject(DaggerGraphState::hotWalletFeatureToggles) + } + @Suppress("MagicNumber") suspend fun prepareTransactionData(data: EthTransactionData): WcTransactionData { val transaction = data.transaction @@ -128,13 +136,13 @@ class WalletConnectSdkHelper { ) } - fun isDemoCard(): Boolean { - val userWallet = userWalletsListManager.selectedUserWalletSync ?: return false + suspend fun isDemoCard(): Boolean { + val userWallet = getSelectedWallet() ?: return false return userWallet is UserWallet.Cold && userWallet.scanResponse.isDemoCard() } private suspend fun getWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager? { - val userWallet = userWalletsListManager.selectedUserWalletSync ?: return null + val userWallet = getSelectedWallet() ?: return null val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) return walletManagerFacade.getOrCreateWalletManager( userWalletId = userWallet.walletId, @@ -481,6 +489,14 @@ class WalletConnectSdkHelper { } } + private suspend fun getSelectedWallet(): UserWallet? { + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + userWalletsListRepository.selectedUserWalletSync() + } else { + userWalletsListManager.selectedUserWalletSync + } + } + private fun getSolanaResultString(signedHash: ByteArray) = "{ signature: \"${signedHash.encodeBase58()}\" }" /** diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt index 7439241a50..499e1861b0 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt @@ -3,6 +3,7 @@ package com.tangem.tap.features.details.redux.walletconnect import com.tangem.blockchain.common.WalletManager import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.common.routing.AppRoute +import com.tangem.domain.models.wallet.UserWallet import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.inject @@ -183,9 +184,19 @@ class WalletConnectMiddleware { private suspend fun getWalletManagers(): List { val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) - val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) - val userWallet = userWalletsListManager.selectedUserWalletSync ?: return emptyList() - + val userWallet = getSelectedWallet() ?: return emptyList() return walletManagerFacade.getStoredWalletManagers(userWallet.walletId) } + + private suspend fun getSelectedWallet(): UserWallet? { + val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) + val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) + val hotWalletFeatureToggles = store.inject(DaggerGraphState::hotWalletFeatureToggles) + + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + userWalletsListRepository.selectedUserWalletSync() + } else { + userWalletsListManager.selectedUserWalletSync + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt index 2d0af3ca0b..6cab06fac6 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt @@ -18,6 +18,7 @@ import com.tangem.domain.wallets.legacy.asLockable import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.tap.common.analytics.events.Settings import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.extensions.onUserWalletSelected @@ -50,6 +51,7 @@ internal class ResetCardModel @Inject constructor( private val userWalletsListManager: UserWalletsListManager, private val analyticsEventHandler: AnalyticsEventHandler, private val cardSettingsInteractor: CardSettingsInteractor, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -259,16 +261,20 @@ internal class ResetCardModel @Inject constructor( private fun finishFullReset() { cardSettingsInteractor.clear() - val newSelectedWallet = userWalletsListManager.selectedUserWalletSync + val newSelectedWallet = getSelectedWalletSyncUseCase.invoke().getOrNull() if (newSelectedWallet != null) { store.dispatchNavigationAction { popTo() } } else { - val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync }.isSuccess - if (isLocked && userWalletsListManager.hasUserWallets) { - store.dispatchNavigationAction { popTo() } - } else { + if (hotWalletFeatureToggles.isHotWalletEnabled) { store.dispatchNavigationAction { replaceAll(AppRoute.Home()) } + } else { + val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync }.isSuccess + if (isLocked && userWalletsListManager.hasUserWallets) { + store.dispatchNavigationAction { popTo() } + } else { + store.dispatchNavigationAction { replaceAll(AppRoute.Home()) } + } } } } diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 643710e4c2..777ff7cc9a 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -24,6 +24,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase import com.tangem.domain.common.LogConfig +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.GetApplicationIdUseCase import com.tangem.domain.notifications.SendPushTokenUseCase import com.tangem.domain.notifications.models.ApplicationId @@ -37,9 +38,9 @@ import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase import com.tangem.domain.staking.FetchStakingTokensUseCase -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.tap.common.extensions.setContext @@ -69,7 +70,6 @@ internal class MainViewModel @Inject constructor( deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase, private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase, private val blockchainSDKFactory: BlockchainSDKFactory, - private val userWalletsListManager: UserWalletsListManager, private val dispatchers: CoroutineDispatcherProvider, private val fetchStakingTokensUseCase: FetchStakingTokensUseCase, private val fetchUserCountryUseCase: FetchUserCountryUseCase, @@ -90,6 +90,7 @@ internal class MainViewModel @Inject constructor( private val multiQuoteUpdater: MultiQuoteUpdater, private val appStateHolder: AppStateHolder, private val environmentConfigStorage: EnvironmentConfigStorage, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel() { @@ -185,13 +186,16 @@ internal class MainViewModel @Inject constructor( } private fun prepareSelectedWalletFeedback() { - userWalletsListManager.selectedUserWallet - .distinctUntilChanged() - .onEach { userWallet -> - Analytics.setContext(userWallet) + getSelectedWalletUseCase.invoke() + .mapLeft { emptyFlow() } + .onRight { + it.distinctUntilChanged() + .onEach { userWallet -> + Analytics.setContext(userWallet) + } + .flowOn(dispatchers.io) + .launchIn(viewModelScope) } - .flowOn(dispatchers.io) - .launchIn(viewModelScope) } private suspend fun fetchStakingTokens() { @@ -214,7 +218,7 @@ internal class MainViewModel @Inject constructor( apiKey = environmentConfig.moonPayApiKey, secretKey = environmentConfig.moonPayApiSecretKey, logEnabled = LogConfig.network.moonPayService, - userWalletProvider = { userWalletsListManager.selectedUserWalletSync }, + userWalletProvider = { getSelectedWalletUseCase.sync().getOrNull() }, ) } diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt index f71c4160f9..97cb5f65e6 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt @@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.model.Currency import com.tangem.tap.proxy.redux.DaggerGraphState @@ -24,9 +25,7 @@ internal class CryptoCurrencyConverter( cryptoCurrencyFactory.createCoin( blockchain = value.blockchain, extraDerivationPath = value.derivationPath, - userWallet = requireNotNull( - store.inject(DaggerGraphState::generalUserWalletsListManager).selectedUserWalletSync, - ), + userWallet = getSelectedWallet(), ), ) is Currency.Token -> requireNotNull( @@ -34,9 +33,7 @@ internal class CryptoCurrencyConverter( sdkToken = value.token, blockchain = value.blockchain, extraDerivationPath = value.derivationPath, - userWallet = requireNotNull( - store.inject(DaggerGraphState::generalUserWalletsListManager).selectedUserWalletSync, - ), + userWallet = getSelectedWallet(), ), ) } @@ -63,4 +60,15 @@ internal class CryptoCurrencyConverter( ) } } + + fun getSelectedWallet(): UserWallet { + val userWalletListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) + val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) + val hotWalletFeatureToggles = store.inject(DaggerGraphState::hotWalletFeatureToggles) + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + requireNotNull(userWalletsListRepository.selectedUserWallet.value) + } else { + requireNotNull(userWalletListManager.selectedUserWalletSync) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt index 767198d50e..d11526b9f4 100644 --- a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt @@ -5,7 +5,7 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.WalletManager import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.ProxyAmount import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -15,13 +15,13 @@ import java.math.BigDecimal class UserWalletManagerImpl( private val walletManagersFacade: WalletManagersFacade, - private val userWalletsListManager: UserWalletsListManager, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val dispatchers: CoroutineDispatcherProvider, ) : UserWalletManager { override fun getWalletId(): String { val selectedUserWallet = requireNotNull( - userWalletsListManager.selectedUserWalletSync, + getSelectedWalletUseCase.sync().getOrNull(), ) { "selectedUserWallet shouldn't be null" } return selectedUserWallet.walletId.stringValue } @@ -62,7 +62,7 @@ class UserWalletManagerImpl( @Throws(IllegalArgumentException::class) private suspend fun getActualWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager { val selectedUserWallet = requireNotNull( - userWalletsListManager.selectedUserWalletSync, + getSelectedWalletUseCase.sync().getOrNull(), ) { "userWallet or userWalletsListManager is null" } val walletManager = withContext(dispatchers.io) { walletManagersFacade.getOrCreateWalletManager( diff --git a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt index 4d59c1f56e..677d7e5ebd 100644 --- a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt +++ b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt @@ -1,7 +1,7 @@ package com.tangem.tap.proxy.di import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.lib.crypto.UserWalletManager import com.tangem.tap.proxy.AppStateHolder import com.tangem.tap.proxy.UserWalletManagerImpl @@ -26,12 +26,12 @@ internal object ProxyModule { @Singleton fun provideUserWalletManager( walletManagersFacade: WalletManagersFacade, - userWalletsListManager: UserWalletsListManager, + getSelectedWalletUseCase: GetSelectedWalletUseCase, dispatchers: CoroutineDispatcherProvider, ): UserWalletManager { return UserWalletManagerImpl( walletManagersFacade = walletManagersFacade, - userWalletsListManager = userWalletsListManager, + getSelectedWalletUseCase = getSelectedWalletUseCase, dispatchers = dispatchers, ) } diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index 5de6bede2a..c64229e83a 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt @@ -12,6 +12,7 @@ import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -35,6 +36,7 @@ internal class DefaultFeedbackRepository( private val walletManagersStore: WalletManagersStore, private val emailSender: EmailSender, private val appVersionProvider: AppVersionProvider, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, ) : FeedbackRepository { private val blockchainsErrors = MutableStateFlow>(emptyMap()) @@ -77,7 +79,7 @@ internal class DefaultFeedbackRepository( } override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) { - val userWallet = userWalletsListManager.selectedUserWalletSync ?: error("UserWallet is not selected") + val userWallet = getSelectedWalletUseCase.sync().getOrNull() ?: error("UserWallet is not selected") blockchainsErrors.update { it.toMutableMap().apply { diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt index cd701c4695..fd2bc36968 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt @@ -9,6 +9,7 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides @@ -28,6 +29,7 @@ internal object FeedbackModule { walletManagersStore: WalletManagersStore, emailSender: EmailSender, appVersionProvider: AppVersionProvider, + getSelectedWalletUseCase: GetSelectedWalletUseCase, ): FeedbackRepository { return DefaultFeedbackRepository( appLogsStore = appLogsStore, @@ -35,6 +37,7 @@ internal object FeedbackModule { walletManagersStore = walletManagersStore, emailSender = emailSender, appVersionProvider = appVersionProvider, + getSelectedWalletUseCase = getSelectedWalletUseCase, ) } diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 23d94a6c78..10e571261d 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -23,11 +23,11 @@ import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM @@ -56,7 +56,7 @@ internal class CreateWalletSelectionModel @Inject constructor( private val saveWalletUseCase: SaveWalletUseCase, private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, - private val userWalletsListManager: UserWalletsListManager, + private val userWalletsListRepository: UserWalletsListRepository, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -146,7 +146,7 @@ internal class CreateWalletSelectionModel @Inject constructor( ) } - private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) if (currency != null) { analyticsEventHandler.send( @@ -154,7 +154,7 @@ internal class CreateWalletSelectionModel @Inject constructor( currency = currency, batch = scanResponse.card.batchId, signInType = SignInType.Card, - walletsCount = userWalletsListManager.walletsCount.toString(), + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), hasBackup = scanResponse.card.backupStatus?.isActive, ), ) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 10ae9abbf8..5c73a404c1 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -26,6 +26,7 @@ import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.ReduxStateHolder @@ -41,6 +42,7 @@ import com.tangem.features.home.api.HomeComponent import com.tangem.features.home.impl.ui.state.HomeUM import com.tangem.features.home.impl.ui.state.Stories import com.tangem.features.home.impl.ui.state.getRestrictedStories +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @@ -76,6 +78,8 @@ internal class HomeModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, private val userWalletsListManager: UserWalletsListManager, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, private val reduxStateHolder: ReduxStateHolder, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -216,7 +220,7 @@ internal class HomeModel @Inject constructor( ) } - private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) if (currency != null) { analyticsEventHandler.send( @@ -224,13 +228,21 @@ internal class HomeModel @Inject constructor( currency = currency, batch = scanResponse.card.batchId, signInType = SignInType.Card, - walletsCount = userWalletsListManager.walletsCount.toString(), + walletsCount = getWalletsCount().toString(), hasBackup = scanResponse.card.backupStatus?.isActive, ), ) } } + private suspend fun getWalletsCount(): Int { + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + userWalletsListRepository.userWalletsSync().size + } else { + userWalletsListManager.walletsCount + } + } + private fun setLoading(isLoading: Boolean) { _uiState.update { it.copy(scanInProgress = isLoading) } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt index dab15c9946..9f56cce80b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt @@ -23,11 +23,11 @@ import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.hotwallet.addexistingwallet.start.entity.AddExistingWalletStartUM @@ -56,7 +56,7 @@ internal class AddExistingWalletStartModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val appRouter: AppRouter, private val urlOpener: UrlOpener, - private val userWalletsListManager: UserWalletsListManager, + private val userWalletsListRepository: UserWalletsListRepository, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -145,7 +145,7 @@ internal class AddExistingWalletStartModel @Inject constructor( ) } - private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) if (currency != null) { analyticsEventHandler.send( @@ -153,7 +153,7 @@ internal class AddExistingWalletStartModel @Inject constructor( currency = currency, batch = scanResponse.card.batchId, signInType = SignInType.Card, - walletsCount = userWalletsListManager.walletsCount.toString(), + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), hasBackup = scanResponse.card.backupStatus?.isActive, ), ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index 24dc8aaa95..e1aca5db5e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -13,12 +13,14 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.asLockable import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.onboarding.v2.TitleProvider import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent @@ -46,6 +48,8 @@ internal class OnboardingEntryModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val uiMessageSender: UiMessageSender, private val userWalletsListManager: UserWalletsListManager, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, ) : Model() { private val params = paramsContainer.require() @@ -197,6 +201,19 @@ internal class OnboardingEntryModel @Inject constructor( } private fun exitComponentScreen() { + // new flow + if (hotWalletFeatureToggles.isHotWalletEnabled) { + modelScope.launch { + if (userWalletsListRepository.userWalletsSync().isEmpty()) { + router.replaceAll(AppRoute.Home()) + } else { + router.replaceAll(AppRoute.Wallet) + } + } + return + } + + // legacy flow if (userWalletsListManager.hasUserWallets) { val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync!! }.getOrElse { false } From 41862d07ee097275ef3fcce636a4018aaecb5cea Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 13:11:29 +0000 Subject: [PATCH 031/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 05f6e8b112..33e667f9d0 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1211" +tangemBlockchainSdk = "develop-1212" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-560" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From dfc3dbc324447651669a049eed2b2e7de14dc267 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 20:34:35 +0700 Subject: [PATCH 032/132] Updated on 2026-08-14 --- .../component/AddCustomTokenComponent.kt | 3 +- .../component/ManageTokensComponent.kt | 11 +- .../component/ManageTokensSource.kt | 14 ++ .../model/ChooseManagedTokensModel.kt | 15 +- .../CustomTokenDerivationInputComponent.kt | 3 +- .../component/CustomTokenFormComponent.kt | 3 +- .../component/CustomTokenSelectorComponent.kt | 5 +- .../impl/DefaultAddCustomTokenComponent.kt | 10 +- .../DefaultCustomTokenSelectorComponent.kt | 2 +- .../impl/DefaultManageTokensComponent.kt | 25 +-- .../preview/PreviewAddCustomTokenComponent.kt | 9 +- .../PreviewCustomTokenSelectorComponent.kt | 3 +- .../preview/PreviewManageTokensComponent.kt | 10 +- .../customtoken/AddCustomTokenConfig.kt | 4 +- .../CustomTokenSelectorDialogConfig.kt | 4 +- .../ManageTokensBottomSheetConfig.kt | 8 +- .../model/CustomTokenFormModel.kt | 20 +- .../model/CustomTokenSelectorModel.kt | 13 +- .../managetokens/model/ManageTokensModel.kt | 67 ++++--- .../model/OnboardingManageTokensModel.kt | 44 ++--- .../ui/AddCustomTokenBottomSheet.kt | 8 +- .../ui/CustomTokenSelectorContent.kt | 6 +- .../utils/CustomCurrencyValidator.kt | 57 ++---- .../list/CustomTokenFormUseCasesFacade.kt | 117 ++++++++++++ .../utils/list/ManageTokensListManager.kt | 178 ++++++------------ .../utils/list/ManageTokensListState.kt | 4 +- .../utils/list/ManageTokensUiActions.kt | 6 +- .../utils/list/ManageTokensUiManager.kt | 88 +-------- .../utils/list/ManageTokensUseCasesFacade.kt | 108 +++++++++++ .../utils/list/ManageTokensWarningDelegate.kt | 98 ++++++++++ 30 files changed, 574 insertions(+), 369 deletions(-) create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensWarningDelegate.kt diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt index aad2b8562d..c45a8667f6 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt @@ -2,12 +2,11 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.domain.models.wallet.UserWalletId interface AddCustomTokenComponent : ComposableBottomSheetComponent { data class Params( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val source: ManageTokensSource, val onDismiss: () -> Unit, val onCurrencyAdded: () -> Unit, diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt index a02c7125ff..e680fabf26 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt @@ -7,9 +7,16 @@ import com.tangem.domain.models.wallet.UserWalletId interface ManageTokensComponent : ComposableContentComponent { data class Params( - val userWalletId: UserWalletId?, + val mode: ManageTokensMode, val source: ManageTokensSource, - ) + ) { + constructor(userWalletId: UserWalletId?, source: ManageTokensSource) : this( + source = source, + mode = userWalletId + ?.let { ManageTokensMode.Wallet(userWalletId) } + ?: ManageTokensMode.None, + ) + } interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt index c1d317c3d8..3c34dd0f34 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt @@ -1,8 +1,22 @@ package com.tangem.features.managetokens.component +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId + enum class ManageTokensSource(val analyticsName: String) { STORIES(analyticsName = "Stories"), ONBOARDING(analyticsName = "Onboarding"), SETTINGS(analyticsName = "Settings"), SEND_VIA_SWAP(analyticsName = "SendViaSwap"), +} + +sealed interface ManageTokensMode { + data class Wallet(val userWalletId: UserWalletId) : ManageTokensMode + data class Account(val accountId: AccountId) : ManageTokensMode + data object None : ManageTokensMode +} + +sealed interface AddCustomTokenMode { + data class Wallet(val userWalletId: UserWalletId) : AddCustomTokenMode + data class Account(val accountId: AccountId) : AddCustomTokenMode } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt index 3c239162b1..a9c9f03c8f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt @@ -22,6 +22,7 @@ import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBot import com.tangem.features.managetokens.choosetoken.entity.ChooseManagedTokenUM import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent.Source +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.analytics.CommonManageTokensAnalyticEvents import com.tangem.features.managetokens.entity.item.CurrencyItemUM @@ -29,6 +30,7 @@ import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.list.ManageTokensListManager +import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade import com.tangem.features.managetokens.utils.list.getLoadingItems import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus @@ -51,12 +53,19 @@ internal class ChooseManagedTokensModel @Inject constructor( private val setShouldShowNotificationUseCase: SetShouldShowNotificationUseCase, private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, + manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, manageTokensListManagerFactory: ManageTokensListManager.Factory, ) : Model() { private val params: ChooseManagedTokensComponent.Params = paramsContainer.require() + private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory + .create(mode = ManageTokensMode.None) private val manageTokensListManager = manageTokensListManagerFactory.create( + scope = modelScope, + source = ManageTokensSource.SEND_VIA_SWAP, + mode = ManageTokensMode.None, + useCasesFacade = useCasesFacade, onCurrencySelect = { token -> bottomSheetNavigation.activate( ChooseManageTokensBottomSheetConfig.SwapTokensBottomSheetConfig( @@ -86,7 +95,7 @@ internal class ChooseManagedTokensModel @Inject constructor( observeSearchQueryChanges() modelScope.launch { - manageTokensListManager.launchPagination(source = ManageTokensSource.SEND_VIA_SWAP, userWalletId = null) + manageTokensListManager.launchPagination() } } @@ -159,7 +168,7 @@ internal class ChooseManagedTokensModel @Inject constructor( } } .sample(periodMillis = 1_000) - .onEach { query -> manageTokensListManager.search(userWalletId = null, query = query) } + .onEach { query -> manageTokensListManager.search(query = query) } .launchIn(modelScope) } @@ -295,7 +304,7 @@ internal class ChooseManagedTokensModel @Inject constructor( if (state.readContent.isInitialBatchLoading || state.readContent.isNextBatchLoading) return false modelScope.launch { - manageTokensListManager.loadMore(userWalletId = null, query = state.readContent.search.query) + manageTokensListManager.loadMore(query = state.readContent.search.query) } return true diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt index 7ec0cff9c9..56f2b08ca9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt @@ -2,13 +2,12 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableDialogComponent -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath internal interface CustomTokenDerivationInputComponent : ComposableDialogComponent { data class Params( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val onConfirm: (SelectedDerivationPath) -> Unit, val onDismiss: () -> Unit, ) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt index 04a9e59d48..2baf3f982f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt @@ -2,7 +2,6 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork @@ -10,7 +9,7 @@ import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork internal interface CustomTokenFormComponent : ComposableContentComponent { data class Params( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val network: SelectedNetwork, val derivationPath: SelectedDerivationPath?, val formValues: CustomTokenFormValues, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt index 6e36ef8eb7..a4ec53c9d9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt @@ -2,7 +2,6 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork @@ -11,13 +10,13 @@ internal interface CustomTokenSelectorComponent : ComposableContentComponent { sealed class Params { data class NetworkSelector( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val selectedNetwork: SelectedNetwork?, val onNetworkSelected: (SelectedNetwork) -> Unit, ) : Params() data class DerivationPathSelector( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val selectedNetwork: SelectedNetwork, val selectedDerivationPath: SelectedDerivationPath?, val onDerivationPathSelected: (SelectedDerivationPath) -> Unit, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt index f4ecc9ceed..1951b3cd44 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt @@ -38,7 +38,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( ) : AddCustomTokenComponent, AppComponentContext by context { private val initialConfiguration = AddCustomTokenConfig( - userWalletId = params.userWalletId, + mode = params.mode, step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, ) @@ -105,7 +105,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( selectorComponentFactory.create( context = childByContext(componentContext), params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = null, onNetworkSelected = ::changeSelectedNetwork, ), @@ -115,7 +115,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( selectorComponentFactory.create( context = childByContext(componentContext), params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = config.selectedNetwork, onNetworkSelected = ::changeSelectedNetwork, ), @@ -125,7 +125,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( selectorComponentFactory.create( context = childByContext(componentContext), params = CustomTokenSelectorComponent.Params.DerivationPathSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = requireNotNull(config.selectedNetwork) { "Network is not selected" }, @@ -138,7 +138,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( formComponentFactory.create( context = childByContext(componentContext), params = CustomTokenFormComponent.Params( - userWalletId = config.userWalletId, + mode = config.mode, network = requireNotNull(config.selectedNetwork) { "Network is not selected" }, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt index edd8f52aa9..44c781e3cc 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt @@ -41,7 +41,7 @@ internal class DefaultCustomTokenSelectorComponent @AssistedInject constructor( is CustomTokenSelectorDialogConfig.CustomDerivationInput -> customTokenDerivationInputComponentFactory.create( context = childByContext(context), params = CustomTokenDerivationInputComponent.Params( - userWalletId = config.userWalletId, + mode = config.mode, onConfirm = model::selectCustomDerivationPath, onDismiss = model.dialogNavigation::dismiss, ), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt index 76ae54a12d..9dc5539ae4 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig import com.tangem.features.managetokens.model.ManageTokensModel @@ -52,18 +53,20 @@ internal class DefaultManageTokensComponent @AssistedInject constructor( private fun bottomSheetChild( config: ManageTokensBottomSheetConfig, componentContext: ComponentContext, - ): ComposableBottomSheetComponent = when (config) { - is ManageTokensBottomSheetConfig.AddCustomToken -> { - addCustomTokenComponentFactory.create( - context = childByContext(componentContext), - params = AddCustomTokenComponent.Params( - userWalletId = config.userWalletId, - source = params.source, - onDismiss = model.bottomSheetNavigation::dismiss, - onCurrencyAdded = model::reloadList, - ), - ) + ): ComposableBottomSheetComponent { + val mode = when (config) { + is ManageTokensBottomSheetConfig.AddWalletCustomToken -> AddCustomTokenMode.Wallet(config.userWalletId) + is ManageTokensBottomSheetConfig.AddAccountCustomToken -> AddCustomTokenMode.Account(config.accountId) } + return addCustomTokenComponentFactory.create( + context = childByContext(componentContext), + params = AddCustomTokenComponent.Params( + mode = mode, + source = params.source, + onDismiss = model.bottomSheetNavigation::dismiss, + onCurrencyAdded = model::reloadList, + ), + ) } @AssistedFactory diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt index eb693e20b1..239866349d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet @@ -14,7 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow internal class PreviewAddCustomTokenComponent( initialState: AddCustomTokenConfig = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")), step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, ), ) : AddCustomTokenComponent { @@ -41,7 +42,7 @@ internal class PreviewAddCustomTokenComponent( AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR -> { PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = null, onNetworkSelected = {}, ), @@ -50,7 +51,7 @@ internal class PreviewAddCustomTokenComponent( AddCustomTokenConfig.Step.NETWORK_SELECTOR -> { PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = config.selectedNetwork, onNetworkSelected = {}, ), @@ -59,7 +60,7 @@ internal class PreviewAddCustomTokenComponent( AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> { PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.DerivationPathSelector( - userWalletId = config.userWalletId, + mode = config.mode, selectedNetwork = config.selectedNetwork!!, selectedDerivationPath = config.selectedDerivationPath!!, onDerivationPathSelected = {}, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt index eca3ff5cc5..9d4485dee8 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM @@ -18,7 +19,7 @@ import kotlinx.collections.immutable.toImmutableList internal class PreviewCustomTokenSelectorComponent( private val params: Params = Params.NetworkSelector( - userWalletId = UserWalletId(stringValue = "321"), + mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")), selectedNetwork = null, onNetworkSelected = {}, ), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index d7caaf672c..c14f0f1836 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -13,6 +13,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM @@ -38,8 +39,10 @@ internal class PreviewManageTokensComponent( value = ManageTokensUM.ManageContent( popBack = {}, items = items, - topBar = if (params.userWalletId != null) { - ManageTokensTopBarUM.ManageContent( + topBar = when (params.mode) { + is ManageTokensMode.Account, + is ManageTokensMode.Wallet, + -> ManageTokensTopBarUM.ManageContent( title = resourceReference(id = R.string.main_manage_tokens), onBackButtonClick = {}, endButton = TopAppBarButtonUM.Icon( @@ -47,8 +50,7 @@ internal class PreviewManageTokensComponent( onClicked = {}, ), ) - } else { - ManageTokensTopBarUM.ReadContent( + ManageTokensMode.None -> ManageTokensTopBarUM.ReadContent( title = resourceReference(R.string.common_search_tokens), onBackButtonClick = {}, ) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt index 767e87805c..e33d46321d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/AddCustomTokenConfig.kt @@ -1,13 +1,13 @@ package com.tangem.features.managetokens.entity.customtoken import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import kotlinx.serialization.Serializable @Serializable internal data class AddCustomTokenConfig( val step: Step, - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, val selectedNetwork: SelectedNetwork? = null, val selectedDerivationPath: SelectedDerivationPath? = null, val formValues: CustomTokenFormValues = CustomTokenFormValues(), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt index 05a447f9d1..fc34e8cdda 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt @@ -1,6 +1,6 @@ package com.tangem.features.managetokens.entity.customtoken -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import kotlinx.serialization.Serializable @Serializable @@ -8,6 +8,6 @@ internal sealed class CustomTokenSelectorDialogConfig { @Serializable data class CustomDerivationInput( - val userWalletId: UserWalletId, + val mode: AddCustomTokenMode, ) : CustomTokenSelectorDialogConfig() } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt index 9e2153b6e5..91a4b7fba3 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensBottomSheetConfig.kt @@ -1,5 +1,6 @@ package com.tangem.features.managetokens.entity.managetokens +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable @@ -7,7 +8,12 @@ import kotlinx.serialization.Serializable internal sealed class ManageTokensBottomSheetConfig { @Serializable - data class AddCustomToken( + data class AddWalletCustomToken( val userWalletId: UserWalletId, ) : ManageTokensBottomSheetConfig() + + @Serializable + data class AddAccountCustomToken( + val accountId: AccountId, + ) : ManageTokensBottomSheetConfig() } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt index 08d5f86574..6e5d19d000 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -12,9 +12,6 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase -import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.component.CustomTokenFormComponent import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM @@ -25,6 +22,7 @@ import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.CustomCurrencyFormBuilder import com.tangem.features.managetokens.utils.CustomCurrencyValidator +import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade import com.tangem.features.managetokens.utils.mapper.mapToDomainModel import com.tangem.features.managetokens.utils.ui.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -40,18 +38,17 @@ import javax.inject.Inject @ModelScoped internal class CustomTokenFormModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val customCurrencyValidator: CustomCurrencyValidator, - private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, - private val derivePublicKeysUseCase: DerivePublicKeysUseCase, - private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, private val messageSender: UiMessageSender, private val customTokenFormManager: CustomCurrencyFormBuilder, private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, + customTokenFormUseCasesFacadeFactory: CustomTokenFormUseCasesFacade.Factory, ) : Model() { private val params: CustomTokenFormComponent.Params = paramsContainer.require() private var createdCurrency: CryptoCurrency? = null + private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode) + private val customCurrencyValidator = CustomCurrencyValidator(useCasesFacade) val state: MutableStateFlow = MutableStateFlow( value = getInitialState(), @@ -117,7 +114,6 @@ internal class CustomTokenFormModel @Inject constructor( .drop(count = 1) // Skip initial state .onEach { formValues -> customCurrencyValidator.validateForm( - userWalletId = params.userWalletId, networkId = params.network.id, derivationPath = getDerivationPath(), formValues = formValues, @@ -156,7 +152,6 @@ internal class CustomTokenFormModel @Inject constructor( private fun validatePrefilledForm() = modelScope.launch { customCurrencyValidator.validateForm( - userWalletId = params.userWalletId, networkId = params.network.id, derivationPath = getDerivationPath(), formValues = state.value.tokenForm.mapToDomainModel(), @@ -169,8 +164,7 @@ internal class CustomTokenFormModel @Inject constructor( isAlreadyAdded: Boolean, isCustom: Boolean, ) = modelScope.launch { - val needToAddDerivation = hasMissedDerivationsUseCase( - userWalletId = params.userWalletId, + val needToAddDerivation = useCasesFacade.hasMissedDerivationsUseCase( networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value), ) @@ -343,13 +337,13 @@ internal class CustomTokenFormModel @Inject constructor( ) analyticsEventHandler.send(event) - derivePublicKeysUseCase(params.userWalletId, listOf(currency)).getOrElse { + useCasesFacade.derivePublicKeysUseCase(listOf(currency)).getOrElse { Timber.e(it, "Failed to derive public keys") showErrorDialog() return@resource } - addCryptoCurrenciesUseCase(params.userWalletId, currency).getOrElse { + useCasesFacade.addCryptoCurrenciesUseCase(currency).getOrElse { Timber.e(it, "Failed to add currency") showErrorDialog() return@resource diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index a919429bd1..3bb119e580 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.managetokens.GetSupportedNetworksUseCase import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.DerivationPathSelector import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.NetworkSelector @@ -83,7 +83,7 @@ internal class CustomTokenSelectorModel @Inject constructor( } private suspend fun loadNetworks(selector: NetworkSelector): List { - return getSupportedNetworks(selector.userWalletId).map { network -> + return getSupportedNetworks(selector.mode).map { network -> network.toCurrencyNetworkModel( isSelected = network.id == selector.selectedNetwork?.id, onSelectedStateChange = { @@ -122,7 +122,7 @@ internal class CustomTokenSelectorModel @Inject constructor( derivationPaths.add(defaultPath) } - getSupportedNetworks(selector.userWalletId) + getSupportedNetworks(selector.mode) .mapNotNullTo(derivationPaths) { network -> if (network.id == selector.selectedNetwork.id) { return@mapNotNullTo null // Skip default path @@ -146,8 +146,9 @@ internal class CustomTokenSelectorModel @Inject constructor( return derivationPaths } - private suspend fun getSupportedNetworks(userWalletId: UserWalletId): List { - return getSupportedNetworksUseCase(userWalletId).getOrElse { e -> + private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e -> val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)) messageSender.send(message) @@ -158,7 +159,7 @@ internal class CustomTokenSelectorModel @Inject constructor( private fun showCustomDerivationInput() { val config = when (params) { is NetworkSelector -> return - is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.userWalletId) + is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.mode) } dialogNavigation.activate(config) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt index 91e1814599..afc1d11bc5 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -17,12 +17,10 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.managetokens.SaveManagedTokensUseCase -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM @@ -30,6 +28,7 @@ import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.list.ChangedCurrencies import com.tangem.features.managetokens.utils.list.ManageTokensListManager +import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -47,18 +46,24 @@ internal class ManageTokensModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val messageSender: UiMessageSender, - private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, - private val saveManagedTokensUseCase: SaveManagedTokensUseCase, private val analyticsEventHandler: AnalyticsEventHandler, manageTokensListManagerFactory: ManageTokensListManager.Factory, + manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, paramsContainer: ParamsContainer, ) : Model() { private val params: ManageTokensComponent.Params = paramsContainer.require() + private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory + .create(mode = params.mode) - private val manageTokensListManager = manageTokensListManagerFactory.create() + private val manageTokensListManager = manageTokensListManagerFactory.create( + scope = modelScope, + source = params.source, + mode = params.mode, + useCasesFacade = useCasesFacade, + ) - val state: MutableStateFlow = MutableStateFlow(getInitialState(params.userWalletId)) + val state: MutableStateFlow = MutableStateFlow(getInitialState()) val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { @@ -79,23 +84,24 @@ internal class ManageTokensModel @Inject constructor( observeSearchQueryChanges() modelScope.launch { - manageTokensListManager.launchPagination(source = params.source, userWalletId = params.userWalletId) + manageTokensListManager.launchPagination() } } fun reloadList() { modelScope.launch { - manageTokensListManager.reload(params.userWalletId) + manageTokensListManager.reload() } } - private fun getInitialState(userWalletId: UserWalletId?): ManageTokensUM { + private fun getInitialState(): ManageTokensUM { analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(params.source)) - return if (userWalletId == null) { - createReadContentModel() - } else { - createManageContentModel() + return when (params.mode) { + is ManageTokensMode.Wallet, + is ManageTokensMode.Account, + -> createManageContentModel() + ManageTokensMode.None -> createReadContentModel() } } @@ -164,7 +170,7 @@ internal class ManageTokensModel @Inject constructor( } } .sample(periodMillis = 1_000) - .onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) } + .onEach { query -> manageTokensListManager.search(query = query) } .launchIn(modelScope) } @@ -261,19 +267,16 @@ internal class ManageTokensModel @Inject constructor( private fun updateChangedItems(currenciesToAdd: ChangedCurrencies, currenciesToRemove: ChangedCurrencies) { modelScope.launch { - val hasMissedDerivations = params.userWalletId?.let { walletId -> - val networks = currenciesToAdd.values - .flatten() - .toSet() - .associate { it.backendId to null } - - hasMissedDerivationsUseCase(walletId, networks) - } + val networks = currenciesToAdd.values + .flatten() + .toSet() + .associate { it.backendId to null } + val hasMissedDerivations = useCasesFacade.hasMissedDerivationsUseCase(networks) state.update { state -> state.copySealed( hasChanges = currenciesToAdd.isNotEmpty() || currenciesToRemove.isNotEmpty(), - needToAddDerivations = hasMissedDerivations ?: false, + needToAddDerivations = hasMissedDerivations, ) } } @@ -284,7 +287,7 @@ internal class ManageTokensModel @Inject constructor( if (state.isInitialBatchLoading || state.isNextBatchLoading) return false modelScope.launch { - manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query) + manageTokensListManager.loadMore(query = state.search.query) } return true @@ -292,9 +295,14 @@ internal class ManageTokensModel @Inject constructor( private fun navigateToAddCustomToken() { analyticsEventHandler.send(CustomTokenAnalyticsEvent.ButtonCustomToken(params.source)) - - params.userWalletId?.let { - bottomSheetNavigation.activate(ManageTokensBottomSheetConfig.AddCustomToken(it)) + when (val portfolio = params.mode) { + is ManageTokensMode.Wallet -> + bottomSheetNavigation + .activate(ManageTokensBottomSheetConfig.AddWalletCustomToken(portfolio.userWalletId)) + is ManageTokensMode.Account -> + bottomSheetNavigation + .activate(ManageTokensBottomSheetConfig.AddAccountCustomToken(portfolio.accountId)) + ManageTokensMode.None -> Unit } } @@ -308,8 +316,7 @@ internal class ManageTokensModel @Inject constructor( ) analyticsEventHandler.send(event) - saveManagedTokensUseCase( - userWalletId = requireNotNull(params.userWalletId), + useCasesFacade.saveManagedTokensUseCase( currenciesToAdd = manageTokensListManager.currenciesToAdd.value, currenciesToRemove = manageTokensListManager.currenciesToRemove.value, ).getOrElse { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt index 30f64dad46..101e04e78c 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt @@ -13,11 +13,10 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.managetokens.SaveManagedTokensUseCase import com.tangem.domain.redux.OnboardingManageTokensAction import com.tangem.domain.redux.ReduxStateHolder -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.OnboardingManageTokensComponent import com.tangem.features.managetokens.entity.item.CurrencyItemUM @@ -25,6 +24,7 @@ import com.tangem.features.managetokens.entity.managetokens.OnboardingManageToke import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.list.ChangedCurrencies import com.tangem.features.managetokens.utils.list.ManageTokensListManager +import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -42,15 +42,22 @@ internal class OnboardingManageTokensModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val messageSender: UiMessageSender, private val reduxStateHolder: ReduxStateHolder, - private val saveManagedTokensUseCase: SaveManagedTokensUseCase, - private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, private val analyticsEventHandler: AnalyticsEventHandler, manageTokensListManagerFactory: ManageTokensListManager.Factory, + manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, paramsContainer: ParamsContainer, ) : Model() { private val params: OnboardingManageTokensComponent.Params = paramsContainer.require() - private val manageTokensListManager = manageTokensListManagerFactory.create() + private val portfolio = ManageTokensMode.Wallet(params.userWalletId) + private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory + .create(mode = portfolio) + private val manageTokensListManager = manageTokensListManagerFactory.create( + scope = modelScope, + source = ManageTokensSource.ONBOARDING, + useCasesFacade = useCasesFacade, + mode = portfolio, + ) val state: MutableStateFlow = MutableStateFlow(getInitialState()) val returnToParentComponentFlow = MutableSharedFlow() @@ -73,10 +80,7 @@ internal class OnboardingManageTokensModel @Inject constructor( observeSearchQueryChanges() modelScope.launch { - manageTokensListManager.launchPagination( - source = ManageTokensSource.ONBOARDING, - userWalletId = params.userWalletId, - ) + manageTokensListManager.launchPagination() } } @@ -119,7 +123,7 @@ internal class OnboardingManageTokensModel @Inject constructor( } } .sample(periodMillis = 1_000) - .onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) } + .onEach { query -> manageTokensListManager.search(query = query) } .launchIn(modelScope) } @@ -208,13 +212,11 @@ internal class OnboardingManageTokensModel @Inject constructor( ) } } else { - val hasMissedDerivations = hasMissedDerivationsUseCase.invoke( - userWalletId = params.userWalletId, - networksWithDerivationPath = currenciesToAdd.values - .flatten() - .toSet() - .associate { it.backendId to null }, - ) + val network = currenciesToAdd.values + .flatten() + .toSet() + .associate { it.backendId to null } + val hasMissedDerivations = useCasesFacade.hasMissedDerivationsUseCase(network = network) state.update { state -> state.copy( actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Continue( @@ -231,7 +233,7 @@ internal class OnboardingManageTokensModel @Inject constructor( if (state.isInitialBatchLoading || state.isNextBatchLoading) return false modelScope.launch { - manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query) + manageTokensListManager.loadMore(query = state.search.query) } return true @@ -255,8 +257,7 @@ internal class OnboardingManageTokensModel @Inject constructor( ) analyticsEventHandler.send(event) - saveManagedTokensUseCase( - userWalletId = requireNotNull(params.userWalletId), + useCasesFacade.saveManagedTokensUseCase( currenciesToAdd = manageTokensListManager.currenciesToAdd.value, currenciesToRemove = manageTokensListManager.currenciesToRemove.value, ).getOrElse { @@ -281,8 +282,7 @@ internal class OnboardingManageTokensModel @Inject constructor( ) { analyticsEventHandler.send(ManageTokensAnalyticEvent.ButtonLater) - saveManagedTokensUseCase( - userWalletId = requireNotNull(params.userWalletId), + useCasesFacade.saveManagedTokensUseCase( currenciesToAdd = manageTokensListManager.currenciesToAdd.value, currenciesToRemove = manageTokensListManager.currenciesToRemove.value, ).getOrElse { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt index 8cd0a27d68..4699454540 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenUM @@ -68,12 +69,13 @@ private fun Preview_AddCustomTokenBottomSheet( } private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider { + private val mode: AddCustomTokenMode get() = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")) override val values: Sequence get() = sequenceOf( PreviewAddCustomTokenComponent(), PreviewAddCustomTokenComponent( initialState = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, step = AddCustomTokenConfig.Step.FORM, selectedNetwork = SelectedNetwork( id = Network.ID(value = "1", derivationPath = Network.DerivationPath.None), @@ -85,7 +87,7 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider< ), PreviewAddCustomTokenComponent( initialState = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, step = AddCustomTokenConfig.Step.NETWORK_SELECTOR, selectedNetwork = SelectedNetwork( id = Network.ID(value = "0", derivationPath = Network.DerivationPath.None), @@ -97,7 +99,7 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider< ), PreviewAddCustomTokenComponent( initialState = AddCustomTokenConfig( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, step = AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR, selectedDerivationPath = SelectedDerivationPath( id = Network.ID(value = "0", derivationPath = Network.DerivationPath.None), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt index 6320fd6743..b7eae1ce2c 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt @@ -32,6 +32,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent import com.tangem.features.managetokens.component.preview.PreviewCustomTokenSelectorComponent import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM @@ -268,12 +269,13 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider : PreviewParameterProvider { private val derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/0") + private val mode: AddCustomTokenMode get() = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")) override val values: Sequence get() = sequenceOf( PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.DerivationPathSelector( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, selectedNetwork = SelectedNetwork( id = Network.ID(value = "0", derivationPath = derivationPath), name = "Ethereum", @@ -291,7 +293,7 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider : ), PreviewCustomTokenSelectorComponent( params = CustomTokenSelectorComponent.Params.NetworkSelector( - userWalletId = UserWalletId(stringValue = "321"), + mode = mode, selectedNetwork = SelectedNetwork( id = Network.ID(value = "0", derivationPath = derivationPath), name = "Ethereum", diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt index bed3b20286..feabc9cb4d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt @@ -1,31 +1,21 @@ package com.tangem.features.managetokens.utils import arrow.core.getOrElse -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase -import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase -import com.tangem.domain.managetokens.FindTokenUseCase -import com.tangem.domain.managetokens.ValidateTokenFormUseCase import com.tangem.domain.managetokens.model.AddCustomTokenForm import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException import com.tangem.domain.managetokens.model.exceptoin.FindTokenException import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveInAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject -@ModelScoped -internal class CustomCurrencyValidator @Inject constructor( - private val validateTokenFormUseCase: ValidateTokenFormUseCase, - private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, - private val findTokenUseCase: FindTokenUseCase, - private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, +internal class CustomCurrencyValidator( + private val useCasesFacade: CustomTokenFormUseCasesFacade, ) { private val validateFormJobHolder = JobHolder() @@ -45,14 +35,13 @@ internal class CustomCurrencyValidator @Inject constructor( } suspend fun validateForm( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, formValues: AddCustomTokenForm.Raw, ) = coroutineScope { updateStatus(Status.Validating) - val result = validateTokenFormUseCase( + val result = useCasesFacade.validateTokenFormUseCase( networkId = networkId, formValues = formValues, ) @@ -73,20 +62,19 @@ internal class CustomCurrencyValidator @Inject constructor( launch { when (validatedForm) { is AddCustomTokenForm.Validated.All -> { - findOrCreateCurrency(userWalletId, networkId, derivationPath, validatedForm) + findOrCreateCurrency(networkId, derivationPath, validatedForm) } is AddCustomTokenForm.Validated.ContractAddressOnly -> { - findToken(userWalletId, networkId, derivationPath, validatedForm) + findToken(networkId, derivationPath, validatedForm) } is AddCustomTokenForm.Validated.Empty -> { - createCurrency(userWalletId, networkId, derivationPath, validatedForm = null) + createCurrency(networkId, derivationPath, validatedForm = null) } } }.saveInAndJoin(validateFormJobHolder) } private suspend fun findOrCreateCurrency( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.All, @@ -96,14 +84,13 @@ internal class CustomCurrencyValidator @Inject constructor( currentState.prevFoundOrCreatedCurrency.contractAddress == validatedForm.contractAddress ) { // No need to search for token again if contract address is not changed - createCurrency(userWalletId, networkId, derivationPath, validatedForm) + createCurrency(networkId, derivationPath, validatedForm) return } updateStatus(Status.SearchingToken) - val foundToken = findTokenUseCase( - userWalletId = userWalletId, + val foundToken = useCasesFacade.findTokenUseCase( contractAddress = validatedForm.contractAddress, networkId = networkId, derivationPath = derivationPath, @@ -121,22 +108,20 @@ internal class CustomCurrencyValidator @Inject constructor( } if (foundToken != null) { - updateStateToValidated(userWalletId, foundToken, fillForm = true, isCustom = false) + updateStateToValidated(foundToken, fillForm = true, isCustom = false) } else { - createCurrency(userWalletId, networkId, derivationPath, validatedForm) + createCurrency(networkId, derivationPath, validatedForm) } } private suspend fun findToken( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.ContractAddressOnly, ) { updateStatus(Status.SearchingToken) - val token = findTokenUseCase( - userWalletId = userWalletId, + val token = useCasesFacade.findTokenUseCase( contractAddress = validatedForm.contractAddress, networkId = networkId, derivationPath = derivationPath, @@ -155,17 +140,15 @@ internal class CustomCurrencyValidator @Inject constructor( return } - updateStateToValidated(userWalletId, token, fillForm = true, isCustom = false) + updateStateToValidated(token, fillForm = true, isCustom = false) } private suspend fun createCurrency( - userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.All?, ) { - val currency = createCryptoCurrencyUseCase( - userWalletId = userWalletId, + val currency = useCasesFacade.createCryptoCurrencyUseCase( networkId = networkId, derivationPath = derivationPath, formValues = validatedForm, @@ -175,20 +158,14 @@ internal class CustomCurrencyValidator @Inject constructor( return } - updateStateToValidated(userWalletId, currency, fillForm = false, isCustom = validatedForm != null) + updateStateToValidated(currency, fillForm = false, isCustom = validatedForm != null) } - private suspend fun updateStateToValidated( - userWalletId: UserWalletId, - currency: CryptoCurrency, - fillForm: Boolean, - isCustom: Boolean, - ) { + private suspend fun updateStateToValidated(currency: CryptoCurrency, fillForm: Boolean, isCustom: Boolean) { val currentStatus = state.value.status if (currentStatus is Status.Validated && currentStatus.currency == currency) return - val isNotAdded = checkIsCurrencyNotAddedUseCase( - userWalletId = userWalletId, + val isNotAdded = useCasesFacade.checkIsCurrencyNotAddedUseCase( networkId = currency.network.id, derivationPath = currency.network.derivationPath, contractAddress = when (currency) { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt new file mode 100644 index 0000000000..6582d27d32 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt @@ -0,0 +1,117 @@ +package com.tangem.features.managetokens.utils.list + +import arrow.core.Either +import arrow.core.NonEmptyList +import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase +import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase +import com.tangem.domain.managetokens.FindTokenUseCase +import com.tangem.domain.managetokens.ValidateTokenFormUseCase +import com.tangem.domain.managetokens.model.AddCustomTokenForm +import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException +import com.tangem.domain.managetokens.model.exceptoin.FindTokenException +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.wallets.usecase.BackendId +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase +import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase +import com.tangem.features.managetokens.component.AddCustomTokenMode +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("LongParameterList") +internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( + private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, + private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val derivePublicKeysUseCase: DerivePublicKeysUseCase, + private val validateTokenFormUseCase: ValidateTokenFormUseCase, + private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, + private val findTokenUseCase: FindTokenUseCase, + private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, + @Assisted private val mode: AddCustomTokenMode, +) { + + suspend fun hasMissedDerivationsUseCase(networksWithDerivationPath: Map): Boolean = + when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> hasMissedDerivationsUseCase.invoke( + userWalletId = mode.userWalletId, + networksWithDerivationPath = networksWithDerivationPath, + ) + } + + suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> addCryptoCurrenciesUseCase.invoke( + userWalletId = mode.userWalletId, + currency = currency, + ) + } + + suspend fun derivePublicKeysUseCase(currencies: List): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> derivePublicKeysUseCase.invoke( + userWalletId = mode.userWalletId, + currencies = currencies, + ) + } + + suspend fun checkIsCurrencyNotAddedUseCase( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke( + userWalletId = mode.userWalletId, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, + ) + } + + suspend fun createCryptoCurrencyUseCase( + networkId: Network.ID, + derivationPath: Network.DerivationPath, + formValues: AddCustomTokenForm.Validated.All?, + ): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> createCryptoCurrencyUseCase.invoke( + userWalletId = mode.userWalletId, + networkId = networkId, + derivationPath = derivationPath, + formValues = formValues, + ) + } + + suspend fun findTokenUseCase( + contractAddress: String, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): Either = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> findTokenUseCase.invoke( + userWalletId = mode.userWalletId, + contractAddress = contractAddress, + networkId = networkId, + derivationPath = derivationPath, + ) + } + + suspend fun validateTokenFormUseCase( + networkId: Network.ID, + formValues: AddCustomTokenForm.Raw, + ): Either, AddCustomTokenForm.Validated> = when (mode) { + is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Wallet -> validateTokenFormUseCase.invoke( + networkId = networkId, + formValues = formValues, + ) + } + + @AssistedFactory + interface Factory { + fun create(mode: AddCustomTokenMode): CustomTokenFormUseCasesFacade + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt index 7664a6ce6e..0964dbe793 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt @@ -9,13 +9,14 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.managetokens.* -import com.tangem.domain.managetokens.model.* +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext +import com.tangem.domain.managetokens.model.ManageTokensUpdateAction +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.features.managetokens.impl.R @@ -24,7 +25,6 @@ import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchListState import com.tangem.pagination.PaginationStatus -import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -42,37 +42,36 @@ import timber.log.Timber @Suppress("LongParameterList", "LargeClass") internal class ManageTokensListManager @AssistedInject constructor( - private val getManagedTokensUseCase: GetManagedTokensUseCase, - private val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase, - private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase, - private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase, - private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, private val messageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val clipboardManager: ClipboardManager, + manageTokensWarningDelegateFactory: ManageTokensWarningDelegate.Factory, + @Assisted private val useCasesFacade: ManageTokensUseCasesFacade, + @Assisted private val source: ManageTokensSource, + @Assisted private val mode: ManageTokensMode, + @Assisted private val scope: CoroutineScope, @Assisted private val onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {}, ) : ManageTokensUiActions { - private lateinit var scope: CoroutineScope - private lateinit var source: ManageTokensSource - private val jobHolder = JobHolder() private val actionsFlow: MutableSharedFlow = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - private val state: MutableStateFlow = MutableStateFlow(ManageTokensListState()) + private val state: MutableStateFlow = + MutableStateFlow(ManageTokensListState(mode = mode)) + private val manageTokensWarningDelegate: ManageTokensWarningDelegate = manageTokensWarningDelegateFactory + .create(mode, source, this) private val changedCurrenciesManager = ChangedCurrenciesManager() private val uiManager = ManageTokensUiManager( state = state, - messageSender = messageSender, + manageTokensWarningDelegate = manageTokensWarningDelegate, dispatchers = dispatchers, actions = this, - scopeProvider = Provider { scope }, - sourceProvider = Provider { source }, + scope = scope, ) val currenciesToAdd: StateFlow = changedCurrenciesManager.currenciesToAdd.asStateFlow() @@ -84,62 +83,61 @@ internal class ManageTokensListManager @AssistedInject constructor( .distinctUntilChanged() val uiItems: Flow> = uiManager.items - suspend fun launchPagination(source: ManageTokensSource, userWalletId: UserWalletId?) = coroutineScope { - scope = this - this@ManageTokensListManager.source = source - - val batchFlow = getManagedTokensUseCase( + suspend fun launchPagination() = coroutineScope { + val loadUserTokensFromRemote = when (mode) { + is ManageTokensMode.Wallet -> source == ManageTokensSource.ONBOARDING + is ManageTokensMode.Account, + ManageTokensMode.None, + -> false + } + val batchFlow = useCasesFacade.getManagedTokensUseCase( context = ManageTokensListBatchingContext( actionsFlow = actionsFlow, coroutineScope = this, ), // only for onboarding case, change carefully and check repository implementation - loadUserTokensFromRemote = userWalletId != null && source == ManageTokensSource.ONBOARDING, + loadUserTokensFromRemote = loadUserTokensFromRemote, ) batchFlow.state - .onEach { state -> updateState(state, userWalletId) } + .onEach { state -> updateState(state) } .flowOn(dispatchers.default) .launchIn(scope = this) .saveIn(jobHolder) // Initial load - reload(userWalletId) + reload() } - suspend fun reload(userWalletId: UserWalletId?) { - state.value = ManageTokensListState() + suspend fun reload() { + state.value = ManageTokensListState(mode = mode) actionsFlow.emit( BatchAction.Reload( - requestParams = ManageTokensListConfig(userWalletId, searchText = null), + requestParams = useCasesFacade.manageTokensListConfig(searchText = null), ), ) } - suspend fun loadMore(userWalletId: UserWalletId?, query: String) { + suspend fun loadMore(query: String) { actionsFlow.emit( BatchAction.LoadMore( - requestParams = ManageTokensListConfig(userWalletId, query), + requestParams = useCasesFacade.manageTokensListConfig(query), ), ) } - suspend fun search(userWalletId: UserWalletId?, query: String) { - state.value = ManageTokensListState(searchQuery = query) + suspend fun search(query: String) { + state.value = ManageTokensListState(mode = mode, searchQuery = query) actionsFlow.emit( BatchAction.Reload( - requestParams = ManageTokensListConfig( - userWalletId = userWalletId, + requestParams = useCasesFacade.manageTokensListConfig( searchText = query, ), ), ) } - private fun updateState( - batchListState: BatchListState>, - userWalletId: UserWalletId?, - ) { + private fun updateState(batchListState: BatchListState>) { state.update { state -> state.copy( status = batchListState.status, @@ -154,7 +152,6 @@ internal class ManageTokensListManager @AssistedInject constructor( ) { state.update { state -> state.copy( - userWalletId = userWalletId, currencyBatches = emptyList(), uiBatches = listOf( Batch( @@ -170,7 +167,7 @@ internal class ManageTokensListManager @AssistedInject constructor( scope.launch { state.update { state -> - val newBatches = getDistinctManagedTokensUseCase(batchListState.data) + val newBatches = useCasesFacade.getDistinctManagedTokensUseCase(batchListState.data) val currentBatches = state.currencyBatches // Distinct until changed @@ -181,9 +178,13 @@ internal class ManageTokensListManager @AssistedInject constructor( return@launch } - val canEditItems = userWalletId != null + val canEditItems = when (state.mode) { + is ManageTokensMode.Account, + is ManageTokensMode.Wallet, + -> true + ManageTokensMode.None -> false + } state.copy( - userWalletId = userWalletId, currencyBatches = newBatches, uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems), canEditItems = canEditItems, @@ -216,10 +217,10 @@ internal class ManageTokensListManager @AssistedInject constructor( sendSelectCurrencyAnalyticsEvent(currency, isSelected = false) } - override fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) { + override fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) { scope.launch { - removeCustomCurrencyUseCase.invoke(userWalletId, currency) - .onRight { reload(userWalletId) } + useCasesFacade.removeCustomCurrencyUseCase(currency) + .onRight { reload() } .onLeft { Timber.e(it) } } } @@ -258,9 +259,8 @@ internal class ManageTokensListManager @AssistedInject constructor( actionsFlow.tryEmit(action) } - override suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean { - return checkHasLinkedTokensUseCase( - userWalletId = userWalletId, + override suspend fun checkHasLinkedTokens(network: Network): Boolean { + return useCasesFacade.checkHasLinkedTokensUseCase( network = network, tempAddedTokens = changedCurrenciesManager.currenciesToAdd.value, tempRemovedTokens = changedCurrenciesManager.currenciesToRemove.value, @@ -269,7 +269,7 @@ internal class ManageTokensListManager @AssistedInject constructor( it, """ Failed to check linked tokens - |- User wallet ID: $userWalletId + |- Mode: $mode |- Network ID: ${network.id} """.trimIndent(), ) @@ -286,18 +286,16 @@ internal class ManageTokensListManager @AssistedInject constructor( } override suspend fun checkCurrencyUnsupportedState( - userWalletId: UserWalletId, sourceNetwork: ManagedCryptoCurrency.SourceNetwork, ): CurrencyUnsupportedState? { - return checkCurrencyUnsupportedUseCase( - userWalletId = userWalletId, + return useCasesFacade.checkCurrencyUnsupportedUseCase( sourceNetwork = sourceNetwork, ).getOrElse { Timber.e( it, """ Failed to check currency unsupported state - |- User wallet ID: $userWalletId + |- Mode: $mode |- Source Network: $sourceNetwork """.trimIndent(), ) @@ -359,8 +357,7 @@ internal class ManageTokensListManager @AssistedInject constructor( if (currency !is ManagedCryptoCurrency.Token) return@launch if (isSelected) { - val userWalletId = state.value.userWalletId - val unsupportedState = userWalletId?.let { checkCurrencyUnsupportedState(it, source) } + val unsupportedState = checkCurrencyUnsupportedState(source) if (unsupportedState != null) { showUnsupportedWarning(unsupportedState) } else { @@ -368,7 +365,7 @@ internal class ManageTokensListManager @AssistedInject constructor( } } else { if (checkNeedToShowRemoveNetworkWarning(currency, source.network)) { - showRemoveNetworkWarning( + manageTokensWarningDelegate.showRemoveNetworkWarning( currency = currency, network = source.network, isCoin = source is ManagedCryptoCurrency.SourceNetwork.Main, @@ -404,67 +401,6 @@ internal class ManageTokensListManager @AssistedInject constructor( messageSender.send(message) } - private suspend fun showRemoveNetworkWarning( - currency: ManagedCryptoCurrency, - network: Network, - isCoin: Boolean, - onConfirm: () -> Unit, - ) { - val userWalletId = state.value.userWalletId - val hasLinkedTokens = if (userWalletId == null || !isCoin) { - false - } else { - checkHasLinkedTokens(userWalletId, network) - } - val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING - - if (hasLinkedTokens) { - showLinkedTokensWarning(currency, network) - } else if (canHideWithoutConfirming) { - onConfirm() - } else { - showHideTokenWarning(currency, onConfirm) - } - } - - private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_unable_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference( - id = R.string.token_details_unable_hide_alert_message, - formatArgs = wrappedList( - currency.name, - currency.symbol, - network.name, - ), - ), - ) - messageSender.send(message) - } - - private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference(R.string.token_details_hide_alert_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.token_details_hide_alert_hide), - warning = true, - onClick = onConfirm, - ) - }, - secondActionBuilder = { cancelAction() }, - ) - - messageSender.send(message) - } - private fun Batch>.currencyIndexById(id: ManagedCryptoCurrency.ID): Int { return data .indexOfFirst { it.id == id } @@ -474,6 +410,12 @@ internal class ManageTokensListManager @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {}): ManageTokensListManager + fun create( + scope: CoroutineScope, + mode: ManageTokensMode, + source: ManageTokensSource, + useCasesFacade: ManageTokensUseCasesFacade, + onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {}, + ): ManageTokensListManager } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt index 42fa36da37..b559a93ae7 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListState.kt @@ -3,7 +3,7 @@ package com.tangem.features.managetokens.utils.list import com.tangem.domain.managetokens.model.ManageTokensListConfig import com.tangem.domain.managetokens.model.ManageTokensUpdateAction import com.tangem.domain.managetokens.model.ManagedCryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.entity.item.CurrencyItemUM import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction @@ -13,7 +13,7 @@ internal typealias ManageTokensBatchAction = BatchAction = PaginationStatus.None, - val userWalletId: UserWalletId? = null, + val mode: ManageTokensMode, val uiBatches: List>> = mutableListOf(), val currencyBatches: List>> = mutableListOf(), val canEditItems: Boolean = true, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt index 8c1873e7da..13a4a44cbd 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiActions.kt @@ -3,7 +3,6 @@ package com.tangem.features.managetokens.utils.list import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId internal interface ManageTokensUiActions { @@ -13,14 +12,13 @@ internal interface ManageTokensUiActions { fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) - fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) + fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) fun checkNeedToShowRemoveNetworkWarning(currency: ManagedCryptoCurrency.Token, network: Network): Boolean - suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean + suspend fun checkHasLinkedTokens(network: Network): Boolean suspend fun checkCurrencyUnsupportedState( - userWalletId: UserWalletId, sourceNetwork: ManagedCryptoCurrency.SourceNetwork, ): CurrencyUnsupportedState? } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt index 8c038934c5..23bb3ba720 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUiManager.kt @@ -1,19 +1,10 @@ package com.tangem.features.managetokens.utils.list -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.message.DialogMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.managetokens.model.ManagedCryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.entity.item.CurrencyItemUM -import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.mapper.toUiModel import com.tangem.features.managetokens.utils.ui.update import com.tangem.pagination.Batch -import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.collections.immutable.ImmutableList @@ -29,19 +20,12 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class ManageTokensUiManager( private val state: MutableStateFlow, - private val messageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, - private val scopeProvider: Provider, - private val sourceProvider: Provider, + private val scope: CoroutineScope, private val actions: ManageTokensUiActions, + private val manageTokensWarningDelegate: ManageTokensWarningDelegate, ) { - private val scope: CoroutineScope - get() = scopeProvider() - - private val source: ManageTokensSource - get() = sourceProvider() - @OptIn(ExperimentalCoroutinesApi::class) val items: Flow> = state .mapLatest { state -> @@ -109,75 +93,11 @@ internal class ManageTokensUiManager( } private fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) = scope.launch(dispatchers.default) { - showRemoveNetworkWarning( + manageTokensWarningDelegate.showRemoveNetworkWarning( currency = currency, network = currency.network, isCoin = currency is ManagedCryptoCurrency.Custom.Coin, - onConfirm = { - val userWalletId = requireNotNull(state.value.userWalletId) { "UserWalletId is null. Can not remove" } - actions.removeCustomCurrency(userWalletId = userWalletId, currency = currency) - }, + onConfirm = { actions.removeCustomCurrency(currency = currency) }, ) } - - private suspend fun showRemoveNetworkWarning( - currency: ManagedCryptoCurrency, - network: Network, - isCoin: Boolean, - onConfirm: () -> Unit, - ) { - val userWalletId = state.value.userWalletId - val hasLinkedTokens = if (userWalletId == null || !isCoin) { - false - } else { - actions.checkHasLinkedTokens(userWalletId, network) - } - val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING - - if (hasLinkedTokens) { - showLinkedTokensWarning(currency, network) - } else if (canHideWithoutConfirming) { - onConfirm() - } else { - showHideTokenWarning(currency, onConfirm) - } - } - - private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_unable_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference( - id = R.string.token_details_unable_hide_alert_message, - formatArgs = wrappedList( - currency.name, - currency.symbol, - network.name, - ), - ), - ) - messageSender.send(message) - } - - private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) { - val message = DialogMessage( - title = resourceReference( - id = R.string.token_details_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference(R.string.token_details_hide_alert_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.token_details_hide_alert_hide), - warning = true, - onClick = onConfirm, - ) - }, - secondActionBuilder = { cancelAction() }, - ) - - messageSender.send(message) - } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt new file mode 100644 index 0000000000..6d246fd5e0 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt @@ -0,0 +1,108 @@ +package com.tangem.features.managetokens.utils.list + +import arrow.core.Either +import arrow.core.left +import com.tangem.domain.managetokens.* +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.managetokens.model.ManageTokensListConfig +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase +import com.tangem.features.managetokens.component.ManageTokensMode +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("LongParameterList") +internal class ManageTokensUseCasesFacade @AssistedInject constructor( + val getManagedTokensUseCase: GetManagedTokensUseCase, + val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase, + private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase, + private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, + private val saveManagedTokensUseCase: SaveManagedTokensUseCase, + @Assisted private val mode: ManageTokensMode, +) { + + private val nonePortfolioError: IllegalStateException + get() = IllegalStateException("Unsupported") + + fun manageTokensListConfig(searchText: String?): ManageTokensListConfig { + val userWalletId: UserWalletId? = when (mode) { + is ManageTokensMode.Account -> TODO("Account") + ManageTokensMode.None -> null + is ManageTokensMode.Wallet -> mode.userWalletId + } + return ManageTokensListConfig(userWalletId, searchText) + } + + suspend fun removeCustomCurrencyUseCase(customCurrency: ManagedCryptoCurrency.Custom): Either { + return when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> removeCustomCurrencyUseCase.invoke( + userWalletId = mode.userWalletId, + customCurrency = customCurrency, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + } + + suspend fun checkHasLinkedTokensUseCase( + network: Network, + tempAddedTokens: Map>, + tempRemovedTokens: Map>, + ): Either { + return when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> checkHasLinkedTokensUseCase.invoke( + userWalletId = mode.userWalletId, + network = network, + tempAddedTokens = tempAddedTokens, + tempRemovedTokens = tempRemovedTokens, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + } + + suspend fun checkCurrencyUnsupportedUseCase( + sourceNetwork: ManagedCryptoCurrency.SourceNetwork, + ): Either { + return when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> checkCurrencyUnsupportedUseCase.invoke( + userWalletId = mode.userWalletId, + sourceNetwork = sourceNetwork, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + } + + suspend fun hasMissedDerivationsUseCase(network: Map): Boolean = when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> hasMissedDerivationsUseCase.invoke( + userWalletId = mode.userWalletId, + networksWithDerivationPath = network, + ) + ManageTokensMode.None -> false + } + + suspend fun saveManagedTokensUseCase( + currenciesToAdd: Map>, + currenciesToRemove: Map>, + ): Either = when (mode) { + is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Wallet -> saveManagedTokensUseCase.invoke( + userWalletId = mode.userWalletId, + currenciesToAdd = currenciesToAdd, + currenciesToRemove = currenciesToRemove, + ) + ManageTokensMode.None -> nonePortfolioError.left() + } + + @AssistedFactory + interface Factory { + fun create(mode: ManageTokensMode): ManageTokensUseCasesFacade + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensWarningDelegate.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensWarningDelegate.kt new file mode 100644 index 0000000000..037e69b449 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensWarningDelegate.kt @@ -0,0 +1,98 @@ +package com.tangem.features.managetokens.utils.list + +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.features.managetokens.component.ManageTokensMode +import com.tangem.features.managetokens.component.ManageTokensSource +import com.tangem.features.managetokens.impl.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class ManageTokensWarningDelegate @AssistedInject constructor( + private val messageSender: UiMessageSender, + @Assisted private val mode: ManageTokensMode, + @Assisted private val source: ManageTokensSource, + @Assisted private val uiActions: ManageTokensUiActions, +) { + + suspend fun showRemoveNetworkWarning( + currency: ManagedCryptoCurrency, + network: Network, + isCoin: Boolean, + onConfirm: () -> Unit, + ) { + val isNonePortfolio = when (mode) { + ManageTokensMode.None -> true + is ManageTokensMode.Account, + is ManageTokensMode.Wallet, + -> false + } + val hasLinkedTokens = if (isNonePortfolio || !isCoin) { + false + } else { + uiActions.checkHasLinkedTokens(network) + } + val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING + + if (hasLinkedTokens) { + showLinkedTokensWarning(currency, network) + } else if (canHideWithoutConfirming) { + onConfirm() + } else { + showHideTokenWarning(currency, onConfirm) + } + } + + private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) { + val message = DialogMessage( + title = resourceReference( + id = R.string.token_details_unable_hide_alert_title, + formatArgs = wrappedList(currency.name), + ), + message = resourceReference( + id = R.string.token_details_unable_hide_alert_message, + formatArgs = wrappedList( + currency.name, + currency.symbol, + network.name, + ), + ), + ) + messageSender.send(message) + } + + private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) { + val message = DialogMessage( + title = resourceReference( + id = R.string.token_details_hide_alert_title, + formatArgs = wrappedList(currency.name), + ), + message = resourceReference(R.string.token_details_hide_alert_message), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.token_details_hide_alert_hide), + warning = true, + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ) + + messageSender.send(message) + } + + @AssistedFactory + interface Factory { + fun create( + mode: ManageTokensMode, + source: ManageTokensSource, + uiActions: ManageTokensUiActions, + ): ManageTokensWarningDelegate + } +} \ No newline at end of file From 513e0a4d7cb9722143eb0ccfc11605b62a5b23fb Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 17:39:17 +0400 Subject: [PATCH 033/132] Updated on 2026-08-14 --- .../java/com/tangem/tap/TangemApplication.kt | 5 +- core/config-toggles/build.gradle.kts | 2 +- .../blockchain/ExcludedBlockchainsManager.kt | 2 - .../impl/DefaultExcludedBlockchainsManager.kt | 80 ------- .../impl/DevExcludedBlockchainsManager.kt | 66 ++++++ .../impl/ProdExcludedBlockchainsManager.kt | 27 +++ .../di/ExcludedBlockchainsManagerModule.kt | 33 ++- .../di/FeatureTogglesManagerModule.kt | 7 +- .../feature/impl/DevFeatureTogglesManager.kt | 17 +- .../storage/FeatureTogglesLocalStorage.kt | 31 --- .../storage/LocalTogglesStorage.kt | 32 ++- .../core/configtoggle/utils/CollectionExt.kt | 10 - .../core/configtoggle/utils/StringLogExt.kt | 16 ++ .../impl/DevExcludedBlockchainsManagerTest.kt | 203 ++++++++++++++++++ .../ProdExcludedBlockchainsManagerTest.kt | 98 +++++++++ .../manager/DevFeatureTogglesManagerTest.kt | 4 +- .../storage/LocalTogglesStorageTest.kt | 110 ---------- .../local/preferences/PreferencesKeys.kt | 2 - .../ExcludedBlockchainsViewModel.kt | 5 +- .../utils/ExcludedBlockchains.kt | 4 - 20 files changed, 460 insertions(+), 294 deletions(-) delete mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DefaultExcludedBlockchainsManager.kt create mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManager.kt create mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManager.kt delete mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt create mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/StringLogExt.kt create mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManagerTest.kt create mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManagerTest.kt delete mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index a413d65b34..ebad984a1a 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -293,16 +293,13 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat Timber.i("APP STARTED") if (BuildConfig.TESTER_MENU_ENABLED) { Timber.i(featureTogglesManager.toString()) + Timber.i(excludedBlockchainsManager.toString()) } foregroundActivityObserver = ForegroundActivityObserver() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) - // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them. runBlocking { - awaitAll( - async { excludedBlockchainsManager.init() }, - ) initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) } diff --git a/core/config-toggles/build.gradle.kts b/core/config-toggles/build.gradle.kts index 368af3bc77..9b23b82a3c 100644 --- a/core/config-toggles/build.gradle.kts +++ b/core/config-toggles/build.gradle.kts @@ -24,7 +24,7 @@ android { } tasks.named("preBuild") { - dependsOn(generateFeatureToggles /*generateExcludedBlockchainToggles*/) + dependsOn(generateFeatureToggles, generateExcludedBlockchainToggles) } tasks.withType().configureEach { diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt index f5db3637b9..4ff86959b7 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/ExcludedBlockchainsManager.kt @@ -3,6 +3,4 @@ package com.tangem.core.configtoggle.blockchain interface ExcludedBlockchainsManager { val excludedBlockchainsIds: Set - - suspend fun init() } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DefaultExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DefaultExcludedBlockchainsManager.kt deleted file mode 100644 index 0a260230e9..0000000000 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DefaultExcludedBlockchainsManager.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.tangem.core.configtoggle.blockchain.impl - -import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectMapSync -import com.tangem.datasource.local.preferences.utils.storeObjectMap - -internal class DefaultExcludedBlockchainsManager( - private val localTogglesStorage: TogglesStorage, - private val appPreferencesStore: AppPreferencesStore, - private val versionProvider: VersionProvider, -) : MutableExcludedBlockchainsManager { - - private var isInitialized: Boolean = false - - private lateinit var currentExcludedBlockchains: MutableMap - private lateinit var localExcludedBlockchains: Map - - override val excludedBlockchainsIds: Set - get() { - if (!isInitialized) error("ExcludedBlockchainsManager is not initialized") - - return currentExcludedBlockchains - .filterValues { it } - .keys - } - - override suspend fun init() { - localTogglesStorage.populate(path = "configs/excluded_blockchains_config") - - val storedExcludedBlockchainsIds = appPreferencesStore.getObjectMapSync( - key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY, - ) - - localExcludedBlockchains = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get().orEmpty()) - .mapValues { (_, isIncluded) -> !isIncluded } - - currentExcludedBlockchains = (localExcludedBlockchains.keys + storedExcludedBlockchainsIds.keys) - .fold(mutableMapOf()) { acc, blockchainId -> - val isExcluded = storedExcludedBlockchainsIds[blockchainId] ?: localExcludedBlockchains[blockchainId] - - requireNotNull(isExcluded) { - "Unable to find $blockchainId in local or stored excluded blockchains" - } - - acc[blockchainId] = isExcluded - acc - } - - isInitialized = true - } - - override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) { - currentExcludedBlockchains[mainnetId] = isExcluded - - storeCurrent() - } - - override fun isMatchLocalConfig(): Boolean { - return currentExcludedBlockchains == localExcludedBlockchains - } - - override suspend fun recoverLocalConfig() { - currentExcludedBlockchains = localExcludedBlockchains.toMutableMap() - - storeCurrent() - } - - private suspend fun storeCurrent() { - appPreferencesStore.storeObjectMap( - key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY, - value = currentExcludedBlockchains, - ) - } -} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManager.kt new file mode 100644 index 0000000000..6f6645c865 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManager.kt @@ -0,0 +1,66 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager +import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.utils.defineTogglesAvailability +import com.tangem.core.configtoggle.utils.toTableString +import com.tangem.core.configtoggle.version.VersionProvider +import kotlinx.coroutines.runBlocking +import kotlin.properties.Delegates + +/** + * [MutableExcludedBlockchainsManager] implementation in dev or mocked build + * + * @property versionProvider application version provider + * @property localTogglesStorage local storage for blockchain toggles + */ +internal class DevExcludedBlockchainsManager( + private val versionProvider: VersionProvider, + private val localTogglesStorage: LocalTogglesStorage, +) : MutableExcludedBlockchainsManager { + + private val fileBlockchainToggles: Map = getFileBlockchainToggles() + private var blockchainTogglesMap: MutableMap by Delegates.notNull() + + override val excludedBlockchainsIds: Set + get() = blockchainTogglesMap.filterValues { !it }.keys + + init { + val savedExcludedBlockchains = runBlocking { localTogglesStorage.getSyncOrEmpty() } + + blockchainTogglesMap = fileBlockchainToggles + .mapValues { (blockchainId, isEnabled) -> + savedExcludedBlockchains[blockchainId] ?: isEnabled + } + .toMutableMap() + } + + override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) { + blockchainTogglesMap[mainnetId] = isExcluded + + localTogglesStorage.store(blockchainTogglesMap) + } + + override fun isMatchLocalConfig(): Boolean { + return blockchainTogglesMap == fileBlockchainToggles + } + + override suspend fun recoverLocalConfig() { + blockchainTogglesMap = fileBlockchainToggles.toMutableMap() + + localTogglesStorage.store(blockchainTogglesMap) + } + + override fun toString(): String { + return blockchainTogglesMap + .filterKeys { it.isNotEmpty() } + .toTableString(tableName = this@DevExcludedBlockchainsManager::class.java.simpleName) + } + + private fun getFileBlockchainToggles(): Map { + val appVersion = versionProvider.get() + + return ExcludedBlockchainToggles.values.defineTogglesAvailability(appVersion = appVersion) + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManager.kt new file mode 100644 index 0000000000..bc9eb689c4 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManager.kt @@ -0,0 +1,27 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager +import com.tangem.core.configtoggle.utils.defineTogglesAvailability +import com.tangem.core.configtoggle.version.VersionProvider + +/** + * [ExcludedBlockchainsManager] implementation in PROD build + * + * @property versionProvider application version provider + */ +internal class ProdExcludedBlockchainsManager( + private val versionProvider: VersionProvider, +) : ExcludedBlockchainsManager { + + override val excludedBlockchainsIds: Set = getBlockchainToggles() + + private fun getBlockchainToggles(): Set { + val appVersion = versionProvider.get() + + return ExcludedBlockchainToggles.values + .defineTogglesAvailability(appVersion = appVersion) + .filterValues { !it } + .keys + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt index 2e4fcddac5..14d9514eae 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/ExcludedBlockchainsManagerModule.kt @@ -3,11 +3,10 @@ package com.tangem.core.configtoggle.di import android.content.Context import com.tangem.core.configtoggle.BuildConfig import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager -import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager -import com.tangem.core.configtoggle.blockchain.impl.DefaultExcludedBlockchainsManager +import com.tangem.core.configtoggle.blockchain.impl.DevExcludedBlockchainsManager +import com.tangem.core.configtoggle.blockchain.impl.ProdExcludedBlockchainsManager import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.version.DefaultVersionProvider -import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides @@ -24,26 +23,20 @@ internal object ExcludedBlockchainsManagerModule { @Singleton fun provideExcludedBlockchainsManager( @ApplicationContext context: Context, - assetLoader: AssetLoader, appPreferencesStore: AppPreferencesStore, ): ExcludedBlockchainsManager { - val localTogglesStorage = LocalTogglesStorage(assetLoader) val versionProvider = DefaultVersionProvider(context) - return DefaultExcludedBlockchainsManager( - localTogglesStorage, - appPreferencesStore, - versionProvider, - ) - } - - @Provides - @Singleton - fun provideMutableExcludedBlockchainsManager( - manager: ExcludedBlockchainsManager, - ): MutableExcludedBlockchainsManager? { - if (!BuildConfig.TESTER_MENU_ENABLED) return null - - return manager as MutableExcludedBlockchainsManager + return if (BuildConfig.TESTER_MENU_ENABLED) { + DevExcludedBlockchainsManager( + versionProvider = versionProvider, + localTogglesStorage = LocalTogglesStorage( + appPreferencesStore = appPreferencesStore, + preferencesKey = LocalTogglesStorage.EXCLUDED_BLOCKCHAINS_KEY, + ), + ) + } else { + ProdExcludedBlockchainsManager(versionProvider = versionProvider) + } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt index 79ad0221fc..877f25842d 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt @@ -5,7 +5,7 @@ import com.tangem.core.configtoggle.BuildConfig import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager -import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage +import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.version.DefaultVersionProvider import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module @@ -30,7 +30,10 @@ internal object FeatureTogglesManagerModule { return if (BuildConfig.TESTER_MENU_ENABLED) { DevFeatureTogglesManager( versionProvider = versionProvider, - featureTogglesLocalStorage = FeatureTogglesLocalStorage(appPreferencesStore), + featureTogglesLocalStorage = LocalTogglesStorage( + appPreferencesStore = appPreferencesStore, + preferencesKey = LocalTogglesStorage.FEATURE_TOGGLES_KEY, + ), ) } else { ProdFeatureTogglesManager(versionProvider = versionProvider) diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index 6677cae6d0..84dd090158 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -3,11 +3,11 @@ package com.tangem.core.configtoggle.feature.impl import androidx.annotation.VisibleForTesting import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager -import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage +import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.utils.defineTogglesAvailability +import com.tangem.core.configtoggle.utils.toTableString import com.tangem.core.configtoggle.version.VersionProvider import kotlinx.coroutines.runBlocking -import java.util.Locale import kotlin.properties.Delegates /** @@ -18,7 +18,7 @@ import kotlin.properties.Delegates */ internal class DevFeatureTogglesManager( private val versionProvider: VersionProvider, - private val featureTogglesLocalStorage: FeatureTogglesLocalStorage, + private val featureTogglesLocalStorage: LocalTogglesStorage, ) : MutableFeatureTogglesManager { private var fileFeatureTogglesMap: Map = getFileFeatureToggles() @@ -52,16 +52,7 @@ internal class DevFeatureTogglesManager( } override fun toString(): String { - return buildString { - append("DevFeatureTogglesManager:\n") - append("|------------------------------------------|-----------|\n") - append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled")) - append("|------------------------------------------|-----------|\n") - featureTogglesMap.entries.forEachIndexed { index, (name, isEnabled) -> - append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled)) - } - append("|------------------------------------------|-----------|") - } + return featureTogglesMap.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName) } private fun getFileFeatureToggles(): Map { diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt deleted file mode 100644 index 7ab7d42891..0000000000 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.core.configtoggle.storage - -import androidx.datastore.preferences.core.stringPreferencesKey -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.utils.getObjectMapSync -import com.tangem.datasource.local.preferences.utils.storeObjectMap - -/** - * Local storage for feature toggles - * - * @property appPreferencesStore app preferences store - * -[REDACTED_AUTHOR] - */ -internal class FeatureTogglesLocalStorage( - private val appPreferencesStore: AppPreferencesStore, -) { - - suspend fun getSyncOrEmpty(): Map { - return appPreferencesStore.getObjectMapSync(key = FEATURE_TOGGLES_KEY) - } - - suspend fun store(value: Map) { - appPreferencesStore.storeObjectMap(key = FEATURE_TOGGLES_KEY, value = value) - } - - private companion object { - - val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } - } -} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt index 59086d9658..fe575dc0ef 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorage.kt @@ -1,24 +1,34 @@ package com.tangem.core.configtoggle.storage -import com.tangem.datasource.asset.loader.AssetLoader -import kotlin.properties.Delegates +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.datasource.local.preferences.utils.storeObjectMap /** - * Storage implementation for storing local feature toggles. - * Feature toggles are declared in file [LOCAL_CONFIG_PATH]. + * Local storage for toggles * - * @property assetLoader asset loader + * @property appPreferencesStore app preferences store + * @property preferencesKey preferences key * [REDACTED_AUTHOR] */ internal class LocalTogglesStorage( - private val assetLoader: AssetLoader, -) : TogglesStorage { + private val appPreferencesStore: AppPreferencesStore, + private val preferencesKey: Preferences.Key, +) { - override var toggles: List by Delegates.notNull() - private set + suspend fun getSyncOrEmpty(): Map { + return appPreferencesStore.getObjectMapSync(key = preferencesKey) + } - override suspend fun populate(path: String) { - toggles = assetLoader.loadList(path) + suspend fun store(value: Map) { + appPreferencesStore.storeObjectMap(key = preferencesKey, value = value) + } + + companion object { + val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } + val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt index d3e54bcac1..052ec8ab62 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt @@ -1,17 +1,7 @@ package com.tangem.core.configtoggle.utils -import com.tangem.core.configtoggle.storage.ConfigToggle import com.tangem.core.configtoggle.version.VersionAvailabilityContract -internal fun List.associateToggles(currentVersion: String): Map { - return associate { localToggle -> - Pair( - first = localToggle.name, - second = VersionAvailabilityContract(currentVersion, localToggle.version), - ) - } -} - internal fun Map.defineTogglesAvailability(appVersion: String?): Map { return if (appVersion == null) { mapValues { false } diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/StringLogExt.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/StringLogExt.kt new file mode 100644 index 0000000000..85293bf111 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/StringLogExt.kt @@ -0,0 +1,16 @@ +package com.tangem.core.configtoggle.utils + +import java.util.Locale + +internal fun Map.toTableString(tableName: String): String { + return buildString { + append("$tableName:\n") + append("|------------------------------------------|-----------|\n") + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled")) + append("|------------------------------------------|-----------|\n") + entries.forEachIndexed { index, (name, isEnabled) -> + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled)) + } + append("|------------------------------------------|-----------|") + } +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManagerTest.kt new file mode 100644 index 0000000000..18eb584b52 --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/DevExcludedBlockchainsManagerTest.kt @@ -0,0 +1,203 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.google.common.truth.Truth +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DevExcludedBlockchainsManagerTest { + + private val versionProvider = mockk() + private val localTogglesStorage = mockk(relaxUnitFun = true) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0") + mockkObject(ExcludedBlockchainToggles) + every { ExcludedBlockchainToggles.values } returns toggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(ExcludedBlockchainToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider, localTogglesStorage) + } + + @Test + fun `successfully initialize manager`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedToggles = mapOf("CHAIN_1" to false, "CHAIN_2" to true) + every { versionProvider.get() } returns appVersion + coEvery { localTogglesStorage.getSyncOrEmpty() } returns savedToggles + + // Act + val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_1") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns null`() = runTest { + // Arrange + every { versionProvider.get() } returns null + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + // Act + val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2") + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if storage returns empty map`() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + // Act + val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).containsExactly("CHAIN_2") + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if storage throws exception`() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + val exception = Exception("Test exception") + coEvery { localTogglesStorage.getSyncOrEmpty() } throws exception + + // Act + val actual = runCatching { DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { + versionProvider.get() + localTogglesStorage.getSyncOrEmpty() + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ExcludeBlockchain { + + @Test + fun excludeBlockchain_changesStatusAndSaves() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + manager.excludeBlockchain("CHAIN_1", false) + + // Act + val actual = manager.excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).contains("CHAIN_1") + coVerify { localTogglesStorage.store(match { it["CHAIN_1"] == false }) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsMatchLocalConfig { + + @Test + fun isMatchLocalConfig_returnsTrueIfMatchesFile() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + + // Act + val actual = manager.isMatchLocalConfig() + + // Assert + Truth.assertThat(actual).isTrue() + } + + @Test + fun isMatchLocalConfig_returnsFalseIfDiffersFromFile() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + manager.excludeBlockchain("CHAIN_1", false) + + // Act + val actual = manager.isMatchLocalConfig() + + // Assert + Truth.assertThat(actual).isFalse() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class RecoverLocalConfig { + + @Test + fun recoverLocalConfig_resetsToFileAndSaves() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val toggles = mapOf("CHAIN_1" to "2.0.0", "CHAIN_2" to "2.0.0") + mockkObject(ExcludedBlockchainToggles) + every { ExcludedBlockchainToggles.values } returns toggles + + coEvery { localTogglesStorage.getSyncOrEmpty() } returns mapOf("CHAIN_1" to true) + + val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) + manager.recoverLocalConfig() + + // Act + val actual = manager.excludedBlockchainsIds + + // Assert + Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2") + + unmockkObject(ExcludedBlockchainToggles) + clearMocks(versionProvider, localTogglesStorage) + } + } +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManagerTest.kt new file mode 100644 index 0000000000..68fdf3c8b6 --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/blockchain/impl/ProdExcludedBlockchainsManagerTest.kt @@ -0,0 +1,98 @@ +package com.tangem.core.configtoggle.blockchain.impl + +import com.google.common.truth.Truth +import com.tangem.core.configtoggle.ExcludedBlockchainToggles +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ProdExcludedBlockchainsManagerTest { + + private val versionProvider = mockk() + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0") + mockkObject(ExcludedBlockchainToggles) + every { ExcludedBlockchainToggles.values } returns toggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(ExcludedBlockchainToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider) + } + + @Test + fun `successfully initialize excluded blockchains`() = runTest { + // Arrange + val appVersion = "1.0.0" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_2") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `all blockchains excluded if versionProvider returns null`() = runTest { + // Arrange + every { versionProvider.get() } returns null + + // Act + val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_1", "CHAIN_2") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `all blockchains excluded if versionProvider returns empty string`() = runTest { + // Arrange + every { versionProvider.get() } returns "" + + // Act + val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds + + // Assert + val expected = setOf("CHAIN_1", "CHAIN_2") + Truth.assertThat(actual).containsExactlyElementsIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `failure initialize if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { ProdExcludedBlockchainsManager(versionProvider) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + } + } +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt index e91cc5d705..4970a9c1cf 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt @@ -5,7 +5,7 @@ import com.tangem.common.test.utils.ProvideTestModels import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel -import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage +import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.version.VersionProvider import io.mockk.* import kotlinx.coroutines.test.runTest @@ -19,7 +19,7 @@ import org.junit.jupiter.params.ParameterizedTest internal class DevFeatureTogglesManagerTest { private val versionProvider = mockk() - private val featureTogglesLocalStorage = mockk(relaxUnitFun = true) + private val featureTogglesLocalStorage = mockk(relaxUnitFun = true) @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt deleted file mode 100644 index b2f5bc4be7..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.tangem.core.configtoggle.storage - -import android.annotation.SuppressLint -import com.google.common.truth.Truth -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.datasource.asset.loader.AssetLoader -import com.tangem.datasource.asset.reader.AssetReader -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.io.IOException - -/** -[REDACTED_AUTHOR] - */ -@SuppressLint("CheckResult") -internal class LocalTogglesStorageTest { - - private val assetReader = mockk() - private val moshi = mockk() - private val jsonAdapter = mockk>>() - - // Impossible to mockk AssetLoader because it implement inline functions - private val assetLoader = AssetLoader( - assetReader = assetReader, - moshi = moshi, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - - private val storage = LocalTogglesStorage(assetLoader) - - @Test - fun `successfully initialize storage`() = runTest { - everyReadingJson() returns json - everyCreatingMoshiAdapter() returns jsonAdapter - everyMappingJson() returns featureToggles - - storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - coVerifyOrder { - assetReader.read(CONFIG_FILE_NAME) - jsonAdapter.fromJson(json) - } - - Truth.assertThat(storage.toggles).containsExactlyElementsIn(featureToggles) - } - - @Test - fun `failure initialize storage if assetReader throws exception`() = runTest { - everyReadingJson() returns json - everyCreatingMoshiAdapter() returns jsonAdapter - everyMappingJson() throws IOException() - - storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - coVerifyOrder { - assetReader.read(CONFIG_FILE_NAME) - jsonAdapter.fromJson(json) - } - - Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList()) - } - - @Test - fun `failure initialize storage if jsonAdapter throws exception`() = runTest { - everyReadingJson() throws IOException() - - storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - coVerifyOrder { assetReader.read(CONFIG_FILE_NAME) } - verifyAll(inverse = true) { jsonAdapter.fromJson(any()) } - - Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList()) - } - - private fun everyReadingJson() = coEvery { assetReader.read(CONFIG_FILE_NAME) } - - private fun everyCreatingMoshiAdapter() = every { - val types = Types.newParameterizedType(List::class.java, ConfigToggle::class.java) - moshi.adapter>(types) - } - - private fun everyMappingJson() = every { jsonAdapter.fromJson(json) } - - private companion object { - const val CONFIG_FILE_NAME = "configs/feature_toggles_config.json" - - val json = """ - [ - { - "name": "INACTIVE_TEST_FEATURE_ENABLED", - "version": "undefined" - }, - { - "name": "ACTIVE2_TEST_FEATURE_ENABLED", - "version": "1.0.0" - } - ] - """.trimIndent() - - val featureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 162d75a40f..8644fb72c8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -56,8 +56,6 @@ object PreferencesKeys { val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } - val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") } - val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") } val IS_TANGEM_TOS_ACCEPTED_KEY by lazy { booleanPreferencesKey(name = "tangem_tos_accepted") } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt index 213bfbc246..3c2015978a 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tester.presentation.excludedblockchains import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tangem.blockchain.common.Blockchain +import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.components.fields.entity.SearchBarUM @@ -25,11 +26,11 @@ import javax.inject.Inject @HiltViewModel internal class ExcludedBlockchainsViewModel @Inject constructor( private val appVersionProvider: AppVersionProvider, - excludedBlockchainsManager: MutableExcludedBlockchainsManager?, + excludedBlockchainsManager: ExcludedBlockchainsManager, ) : ViewModel() { private val excludedBlockchainsManager: MutableExcludedBlockchainsManager = - requireNotNull(excludedBlockchainsManager) { + requireNotNull(excludedBlockchainsManager as? MutableExcludedBlockchainsManager) { "Mutable excluded blockchains manager can't be null when tester actions is available" } diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt index 6e2cb4877d..e2d3d72ea2 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/ExcludedBlockchains.kt @@ -28,10 +28,6 @@ class ExcludedBlockchains @Inject internal constructor( excludedBlockchainsManager = object : ExcludedBlockchainsManager { override val excludedBlockchainsIds: Set = emptySet() - - override suspend fun init() { - /* no-op */ - } }, ) From 17d7fb3c0209728aa99cc06860926323ad7cc864 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 19:21:37 +0300 Subject: [PATCH 034/132] Updated on 2026-08-14 --- .../com/tangem/tap/ApplicationEntryPoint.kt | 4 +- .../java/com/tangem/tap/TangemApplication.kt | 8 +-- .../tap/di/domain/FeedbackDomainModule.kt | 6 +- .../domain/scanCard/LegacyScanProcessor.kt | 2 +- .../ui/dialogs/WalletActivationErrorDialog.kt | 2 +- .../tap/proxy/redux/DaggerGraphState.kt | 4 +- .../com/tangem/common/routing/AppRoute.kt | 7 +- data/feedback/build.gradle.kts | 3 +- .../feedback/DefaultFeedbackRepository.kt | 38 ++++++++++- .../feedback/converters/CardInfoConverter.kt | 41 ------------ .../converters/WalletMetaInfoConverter.kt | 66 +++++++++++++++++++ .../tangem/data/feedback/di/FeedbackModule.kt | 6 ++ .../tangem/domain/feedback/models/CardInfo.kt | 21 ------ .../feedback/models/FeedbackEmailType.kt | 26 ++++---- .../domain/feedback/models/WalletMetaInfo.kt | 22 +++++++ .../domain/feedback/FeedbackDataBuilder.kt | 17 ++--- .../domain/feedback/GetCardInfoUseCase.kt | 23 ------- .../feedback/GetWalletMetaInfoUseCase.kt | 28 ++++++++ .../feedback/SendFeedbackEmailUseCase.kt | 4 +- .../feedback/repository/FeedbackRepository.kt | 4 +- .../utils/EmailMessageBodyResolver.kt | 49 +++++++------- .../feedback/utils/EmailSubjectResolver.kt | 2 +- .../features/details/model/DetailsModel.kt | 65 ++++++++---------- .../model/MultiWalletCreateWalletModel.kt | 7 +- .../model/MultiWalletFinalizeModel.kt | 7 +- .../model/MultiWalletSeedPhraseModel.kt | 7 +- .../impl/DefaultOnboardingStepperComponent.kt | 6 +- .../v2/send/confirm/model/SendConfirmModel.kt | 16 ++--- .../features/send/v2/send/model/SendModel.kt | 15 ++--- .../confirm/model/NFTSendConfirmModel.kt | 16 ++--- .../send/v2/sendnft/model/NFTSendModel.kt | 15 ++--- .../impl/presentation/model/StakingModel.kt | 16 ++--- .../swap/v2/impl/common/SwapAlertFactory.kt | 14 ++-- .../tangem/feature/swap/model/SwapModel.kt | 13 ++-- .../wallet/model/intents/VisaWalletIntents.kt | 8 +-- .../intents/WalletWarningsClickIntents.kt | 24 ++----- 36 files changed, 311 insertions(+), 301 deletions(-) delete mode 100644 data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt create mode 100644 data/feedback/src/main/java/com/tangem/data/feedback/converters/WalletMetaInfoConverter.kt delete mode 100644 domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/CardInfo.kt create mode 100644 domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/WalletMetaInfo.kt delete mode 100644 domain/feedback/src/main/java/com/tangem/domain/feedback/GetCardInfoUseCase.kt create mode 100644 domain/feedback/src/main/java/com/tangem/domain/feedback/GetWalletMetaInfoUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index aa3d4f2a9a..ce53e6dc05 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -29,7 +29,7 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.core.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase @@ -107,7 +107,7 @@ interface ApplicationEntryPoint { fun getSendFeedbackEmailUseCase(): SendFeedbackEmailUseCase - fun getGetCardInfoUseCase(): GetCardInfoUseCase + fun getWalletMetaInfoUseCase(): GetWalletMetaInfoUseCase fun getUrlOpener(): UrlOpener diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index ebad984a1a..4c35b0ee8f 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -47,7 +47,7 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.common.LogConfig -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase @@ -166,8 +166,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase get() = entryPoint.getSendFeedbackEmailUseCase() - private val getCardInfoUseCase: GetCardInfoUseCase - get() = entryPoint.getGetCardInfoUseCase() + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase + get() = entryPoint.getWalletMetaInfoUseCase() private val urlOpener get() = entryPoint.getUrlOpener() @@ -359,7 +359,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat settingsRepository = settingsRepository, blockchainSDKFactory = blockchainSDKFactory, sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, - getCardInfoUseCase = getCardInfoUseCase, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, issuersConfigStorage = issuersConfigStorage, urlOpener = urlOpener, shareManager = shareManager, diff --git a/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt index b905aa7edc..48bfb0b921 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt @@ -1,7 +1,7 @@ package com.tangem.tap.di.domain import android.content.Context -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.repository.FeedbackRepository @@ -18,8 +18,8 @@ internal object FeedbackDomainModule { @Provides @Singleton - fun provideGetCardInfoUseCase(feedbackRepository: FeedbackRepository): GetCardInfoUseCase { - return GetCardInfoUseCase(feedbackRepository = feedbackRepository) + fun provideGetCardInfoUseCase(feedbackRepository: FeedbackRepository): GetWalletMetaInfoUseCase { + return GetWalletMetaInfoUseCase(feedbackRepository = feedbackRepository) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index 386cddb495..ea780d4fa5 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -265,7 +265,7 @@ internal class LegacyScanProcessor @Inject constructor( onOk = { mainScope.launch { onSuccess() } }, onSupportClick = { val cardInfo = - store.inject(DaggerGraphState::getCardInfoUseCase).invoke(scanResponse).getOrNull() + store.inject(DaggerGraphState::getWalletMetaInfoUseCase).invoke(scanResponse).getOrNull() ?: error("CardInfo must be not null") scope.launch { diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt index d5851ab0ed..880f9177a0 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt @@ -30,7 +30,7 @@ object WalletActivationErrorDialog { val scanResponse = store.state.globalState.scanResponse ?: error("ScanResponse must be not null") - val cardInfo = store.inject(DaggerGraphState::getCardInfoUseCase).invoke(scanResponse).getOrNull() + val cardInfo = store.inject(DaggerGraphState::getWalletMetaInfoUseCase).invoke(scanResponse).getOrNull() ?: error("CardInfo must be not null") scope.launch { diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index e0b2794648..f7e6c83846 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -22,7 +22,7 @@ import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.core.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase @@ -63,7 +63,7 @@ data class DaggerGraphState( val settingsRepository: SettingsRepository? = null, val blockchainSDKFactory: BlockchainSDKFactory? = null, val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase? = null, - val getCardInfoUseCase: GetCardInfoUseCase? = null, + val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase? = null, val issuersConfigStorage: IssuersConfigStorage? = null, val urlOpener: UrlOpener? = null, val shareManager: ShareManager? = null, 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 cfa1070c25..20d91d8960 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 @@ -1,14 +1,13 @@ package com.tangem.common.routing import android.os.Bundle -import com.tangem.common.routing.AppRoute.ManageTokens.Source import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.common.routing.entity.SerializableIntent import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.feedback.models.CardInfo +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency @@ -83,8 +82,8 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Usedesk( - val cardInfo: CardInfo, - ) : AppRoute(path = "/usedesk/${cardInfo.cardId}") + val walletMetaInfo: WalletMetaInfo, + ) : AppRoute(path = "/usedesk/${walletMetaInfo.userWalletId}") @Serializable data class CardSettings( diff --git a/data/feedback/build.gradle.kts b/data/feedback/build.gradle.kts index 9826df2814..08b86b9db4 100644 --- a/data/feedback/build.gradle.kts +++ b/data/feedback/build.gradle.kts @@ -10,7 +10,6 @@ android { } dependencies { - // region AndroidX libraries implementation(deps.androidx.datastore) // endregion @@ -37,6 +36,8 @@ dependencies { // endregion + // Feature modules + implementation(projects.features.hotWallet.api) implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index c64229e83a..54cb04d95b 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt @@ -4,12 +4,14 @@ import android.os.Build import com.tangem.blockchain.common.Blockchain import com.tangem.core.navigation.email.EmailSender import com.tangem.data.feedback.converters.BlockchainInfoConverter -import com.tangem.data.feedback.converters.CardInfoConverter +import com.tangem.data.feedback.converters.WalletMetaInfoConverter import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.repository.FeedbackRepository 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.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase @@ -24,14 +26,19 @@ import java.io.File * * @property appLogsStore app logs store * @property userWalletsListManager user wallets list manager + * @property useNewUserWalletsRepository flag to use new user wallets repository + * @property userWalletsListRepository user wallets repository * @property walletManagersStore wallet managers store * @property emailSender email sender * @property appVersionProvider app version provider * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class DefaultFeedbackRepository( private val appLogsStore: AppLogsStore, + private val useNewUserWalletsRepository: Boolean, + private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListManager: UserWalletsListManager, private val walletManagersStore: WalletManagersStore, private val emailSender: EmailSender, @@ -41,12 +48,21 @@ internal class DefaultFeedbackRepository( private val blockchainsErrors = MutableStateFlow>(emptyMap()) - override fun getCardInfo(scanResponse: ScanResponse) = CardInfoConverter.convert(value = scanResponse) + override suspend fun getUserWalletMetaInfo(userWalletId: UserWalletId): WalletMetaInfo { + val userWallet = getUserWalletById(userWalletId) + return userWallet?.let { + WalletMetaInfoConverter.convert(it) + } ?: WalletMetaInfo(userWalletId) + } + + override fun getUserWalletMetaInfo(scanResponse: ScanResponse): WalletMetaInfo { + return WalletMetaInfoConverter.convert(value = scanResponse) + } override fun getUserWalletsInfo(userWalletId: UserWalletId?): UserWalletsInfo { return UserWalletsInfo( selectedUserWalletId = userWalletId?.stringValue ?: "card isn't activated", - totalUserWallets = userWalletsListManager.walletsCount, + totalUserWallets = totalUserWallets(), ) } @@ -108,4 +124,20 @@ internal class DefaultFeedbackRepository( ), ) } + + private suspend fun getUserWalletById(userWalletId: UserWalletId): UserWallet? { + return if (useNewUserWalletsRepository) { + userWalletsListRepository.userWalletsSync().find { it.walletId == userWalletId } + } else { + userWalletsListManager.userWalletsSync.find { it.walletId == userWalletId } + } + } + + private fun totalUserWallets(): Int { + return if (useNewUserWalletsRepository) { + userWalletsListRepository.userWallets.value?.size ?: 0 + } else { + userWalletsListManager.walletsCount + } + } } \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt deleted file mode 100644 index 1b17450f2d..0000000000 --- a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.data.feedback.converters - -import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin -import com.tangem.domain.card.common.TapWorkarounds.isVisa -import com.tangem.domain.card.common.util.getBackupCardsCount -import com.tangem.domain.feedback.models.CardInfo -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.utils.converter.Converter - -/** - * Converter from [ScanResponse] to [CardInfo] - * -[REDACTED_AUTHOR] - */ -internal object CardInfoConverter : Converter { - - override fun convert(value: ScanResponse): CardInfo { - return with(value) { - CardInfo( - userWalletId = createUserWalletId(scanResponse = value), - cardId = card.cardId, - cardsCount = value.getBackupCardsCount()?.toString() ?: "0", - firmwareVersion = card.firmwareVersion.stringValue, - cardBlockchain = walletData?.blockchain, - signedHashesList = card.wallets.map { - CardInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString()) - }, - isImported = value.card.wallets.any(CardDTO.Wallet::isImported), - isStart2Coin = value.card.isStart2Coin, - isVisa = value.card.isVisa, - ) - } - } - - private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId? { - return UserWalletIdBuilder.scanResponse(scanResponse = scanResponse).build() - } -} \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/WalletMetaInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/WalletMetaInfoConverter.kt new file mode 100644 index 0000000000..ceaabe5809 --- /dev/null +++ b/data/feedback/src/main/java/com/tangem/data/feedback/converters/WalletMetaInfoConverter.kt @@ -0,0 +1,66 @@ +package com.tangem.data.feedback.converters + +import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.card.common.TapWorkarounds.isVisa +import com.tangem.domain.card.common.util.getBackupCardsCount +import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.models.scan.CardDTO +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.wallets.builder.UserWalletIdBuilder +import com.tangem.utils.converter.Converter + +/** + * Converter from [UserWallet] to [WalletMetaInfo] + * +[REDACTED_AUTHOR] + */ +internal object WalletMetaInfoConverter : Converter { + + override fun convert(value: UserWallet): WalletMetaInfo { + return when (value) { + is UserWallet.Cold -> { + WalletMetaInfo( + userWalletId = value.walletId, + cardId = value.scanResponse.card.cardId, + cardsCount = value.getBackupCardsCount()?.toString() ?: "0", + firmwareVersion = value.scanResponse.card.firmwareVersion.stringValue, + cardBlockchain = value.scanResponse.walletData?.blockchain, + signedHashesList = value.scanResponse.card.wallets.map { + WalletMetaInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString()) + }, + isImported = value.scanResponse.card.wallets.any(CardDTO.Wallet::isImported), + isStart2Coin = value.scanResponse.card.isStart2Coin, + isVisa = value.scanResponse.card.isVisa, + ) + } + is UserWallet.Hot -> { + WalletMetaInfo( + userWalletId = value.walletId, + hotWalletIsBackedUp = value.backedUp, + ) + } + } + } + + fun convert(value: ScanResponse): WalletMetaInfo { + return WalletMetaInfo( + userWalletId = createUserWalletId(value), + cardId = value.card.cardId, + cardsCount = value.getBackupCardsCount()?.toString() ?: "0", + firmwareVersion = value.card.firmwareVersion.stringValue, + cardBlockchain = value.walletData?.blockchain, + signedHashesList = value.card.wallets.map { + WalletMetaInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString()) + }, + isImported = value.card.wallets.any(CardDTO.Wallet::isImported), + isStart2Coin = value.card.isStart2Coin, + isVisa = value.card.isVisa, + ) + } + + private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId? { + return UserWalletIdBuilder.scanResponse(scanResponse = scanResponse).build() + } +} \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt index fd2bc36968..067feab7a8 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt @@ -6,10 +6,12 @@ import com.tangem.data.feedback.DefaultFeedbackFeatureToggles import com.tangem.data.feedback.DefaultFeedbackRepository import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides @@ -26,6 +28,8 @@ internal object FeedbackModule { fun provideFeedbackRepository( appLogsStore: AppLogsStore, userWalletsListManager: UserWalletsListManager, + userWalletsListRepository: UserWalletsListRepository, + hotWalletFeatureToggles: HotWalletFeatureToggles, walletManagersStore: WalletManagersStore, emailSender: EmailSender, appVersionProvider: AppVersionProvider, @@ -37,6 +41,8 @@ internal object FeedbackModule { walletManagersStore = walletManagersStore, emailSender = emailSender, appVersionProvider = appVersionProvider, + userWalletsListRepository = userWalletsListRepository, + useNewUserWalletsRepository = hotWalletFeatureToggles.isHotWalletEnabled, getSelectedWalletUseCase = getSelectedWalletUseCase, ) } diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/CardInfo.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/CardInfo.kt deleted file mode 100644 index 527416d699..0000000000 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/CardInfo.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.domain.feedback.models - -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.serialization.Serializable - -@Serializable -data class CardInfo( - val userWalletId: UserWalletId?, - val cardId: String, - val firmwareVersion: String, - val cardsCount: String, - val cardBlockchain: String?, - val signedHashesList: List, - val isImported: Boolean, - val isStart2Coin: Boolean, - val isVisa: Boolean, -) { - - @Serializable - data class SignedHashes(val curve: String, val total: String?) -} \ No newline at end of file diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 7853427ca5..5649d73a23 100644 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -9,32 +9,32 @@ import com.tangem.domain.visa.model.VisaTxDetails */ sealed interface FeedbackEmailType { - val cardInfo: CardInfo? + val walletMetaInfo: WalletMetaInfo? /** User initiate request yourself. Example, button on DetailsScreen or OnboardingScreen */ - data class DirectUserRequest(override val cardInfo: CardInfo) : FeedbackEmailType + data class DirectUserRequest(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType /** User rate the app as "can be better" */ - data class RateCanBeBetter(override val cardInfo: CardInfo) : FeedbackEmailType + data class RateCanBeBetter(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType /** User has problem with scanning */ data object ScanningProblem : FeedbackEmailType { - override val cardInfo: CardInfo? = null + override val walletMetaInfo: WalletMetaInfo? = null } /** User has problem with sending transaction */ - data class TransactionSendingProblem(override val cardInfo: CardInfo) : FeedbackEmailType + data class TransactionSendingProblem(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType /** User has problem with staking */ data class StakingProblem( - override val cardInfo: CardInfo, + override val walletMetaInfo: WalletMetaInfo, val validatorName: String?, val transactionTypes: List, val unsignedTransactions: List, ) : FeedbackEmailType data class SwapProblem( - override val cardInfo: CardInfo, + override val walletMetaInfo: WalletMetaInfo, val providerName: String, val txId: String, ) : FeedbackEmailType @@ -46,23 +46,23 @@ sealed interface FeedbackEmailType { * @property currencyName currency name */ data class CurrencyDescriptionError(val currencyId: String, val currencyName: String) : FeedbackEmailType { - override val cardInfo: CardInfo? = null + override val walletMetaInfo: WalletMetaInfo? = null } - data class PreActivatedWallet(override val cardInfo: CardInfo) : FeedbackEmailType + data class PreActivatedWallet(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType data object CardAttestationFailed : FeedbackEmailType { - override val cardInfo: CardInfo? = null + override val walletMetaInfo: WalletMetaInfo? = null } sealed class Visa : FeedbackEmailType { - data class DirectUserRequest(override val cardInfo: CardInfo) : Visa() + data class DirectUserRequest(override val walletMetaInfo: WalletMetaInfo) : Visa() - data class Activation(override val cardInfo: CardInfo) : Visa() + data class Activation(override val walletMetaInfo: WalletMetaInfo) : Visa() data class Dispute( val visaTxDetails: VisaTxDetails, - override val cardInfo: CardInfo, + override val walletMetaInfo: WalletMetaInfo, ) : Visa() } } \ No newline at end of file diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/WalletMetaInfo.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/WalletMetaInfo.kt new file mode 100644 index 0000000000..8b9f46f512 --- /dev/null +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/WalletMetaInfo.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.feedback.models + +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +data class WalletMetaInfo( + val userWalletId: UserWalletId?, + val hotWalletIsBackedUp: Boolean? = null, + val cardId: String? = null, + val firmwareVersion: String? = null, + val cardsCount: String? = null, + val cardBlockchain: String? = null, + val signedHashesList: List? = null, + val isImported: Boolean? = null, + val isStart2Coin: Boolean? = null, + val isVisa: Boolean? = null, +) { + + @Serializable + data class SignedHashes(val curve: String, val total: String?) +} \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index 7d0f5aa49f..e5f65f4c77 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -44,13 +44,14 @@ internal class FeedbackDataBuilder { builder.appendKeyValue("Total saved wallets", userWalletsInfo.totalUserWallets.toString()) } - fun addCardInfo(cardInfo: CardInfo) { - builder.appendKeyValue("Card ID", cardInfo.cardId) - builder.appendKeyValue("Firmware version", cardInfo.firmwareVersion) - builder.appendKeyValue("Linked cards count", cardInfo.cardsCount) - builder.appendKeyValue("Has seed phrase", cardInfo.isImported.toString()) - builder.appendKeyValue("Card Blockchain", cardInfo.cardBlockchain) - builder.appendSignedHashes(cardInfo.signedHashesList) + fun addUserWalletMetaInfo(walletMetaInfo: WalletMetaInfo) { + builder.appendKeyValue("Mobile Wallet is backed up", walletMetaInfo.hotWalletIsBackedUp?.toString()) + builder.appendKeyValue("Card ID", walletMetaInfo.cardId) + builder.appendKeyValue("Firmware version", walletMetaInfo.firmwareVersion) + builder.appendKeyValue("Linked cards count", walletMetaInfo.cardsCount) + builder.appendKeyValue("Has seed phrase", walletMetaInfo.isImported?.toString()) + builder.appendKeyValue("Card Blockchain", walletMetaInfo.cardBlockchain) + walletMetaInfo.signedHashesList?.let { builder.appendSignedHashes(it) } } fun addBlockchainInfoList(blockchainInfoList: List) { @@ -146,7 +147,7 @@ internal class FeedbackDataBuilder { append("$keyValuePrefix$value\n") } - private fun StringBuilder.appendSignedHashes(signedHashesList: List) { + private fun StringBuilder.appendSignedHashes(signedHashesList: List) { signedHashesList.forEach { appendKeyValue("Signed hashes [${it.curve}]", it.total) } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/GetCardInfoUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/GetCardInfoUseCase.kt deleted file mode 100644 index f2ecf40fca..0000000000 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/GetCardInfoUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.domain.feedback - -import arrow.core.Either -import arrow.core.Either.Companion.catch -import com.tangem.domain.feedback.models.CardInfo -import com.tangem.domain.feedback.repository.FeedbackRepository -import com.tangem.domain.models.scan.ScanResponse - -/** - * UseCase for creating 'CardInfo' - * - * @property feedbackRepository feedback repository - * -[REDACTED_AUTHOR] - */ -class GetCardInfoUseCase( - private val feedbackRepository: FeedbackRepository, -) { - - operator fun invoke(scanResponse: ScanResponse): Either = catch { - feedbackRepository.getCardInfo(scanResponse) - } -} \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/GetWalletMetaInfoUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/GetWalletMetaInfoUseCase.kt new file mode 100644 index 0000000000..a3bd2788d4 --- /dev/null +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/GetWalletMetaInfoUseCase.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.feedback + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWalletId + +/** + * UseCase for creating 'UserWalletMetaInfo' from [UserWalletId] or [ScanResponse] + * + * @property feedbackRepository feedback repository + * +[REDACTED_AUTHOR] + */ +class GetWalletMetaInfoUseCase( + private val feedbackRepository: FeedbackRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either = catch { + feedbackRepository.getUserWalletMetaInfo(userWalletId) + } + + operator fun invoke(scanResponse: ScanResponse): Either = catch { + feedbackRepository.getUserWalletMetaInfo(scanResponse) + } +} \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt index 86cd5c8945..a8d5177b41 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt @@ -37,8 +37,8 @@ class SendFeedbackEmailUseCase( private fun getAddress(type: FeedbackEmailType): String { return when { - type is FeedbackEmailType.Visa || type.cardInfo?.isVisa == true -> TANGEM_VISA_SUPPORT_EMAIL - type.cardInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL + type is FeedbackEmailType.Visa || type.walletMetaInfo?.isVisa == true -> TANGEM_VISA_SUPPORT_EMAIL + type.walletMetaInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL else -> TANGEM_SUPPORT_EMAIL } } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt index 55ee6e8be1..3661f00b48 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt @@ -7,7 +7,9 @@ import java.io.File interface FeedbackRepository { - fun getCardInfo(scanResponse: ScanResponse): CardInfo + suspend fun getUserWalletMetaInfo(userWalletId: UserWalletId): WalletMetaInfo + + fun getUserWalletMetaInfo(scanResponse: ScanResponse): WalletMetaInfo fun getUserWalletsInfo(userWalletId: UserWalletId?): UserWalletsInfo diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index b976293f59..5a94a88857 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -1,7 +1,7 @@ package com.tangem.domain.feedback.utils import com.tangem.domain.feedback.FeedbackDataBuilder -import com.tangem.domain.feedback.models.CardInfo +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository import com.tangem.domain.visa.model.VisaTxDetails @@ -20,37 +20,40 @@ internal class EmailMessageBodyResolver( /** Resolve email message body by [type] */ suspend fun resolve(type: FeedbackEmailType): String = with(FeedbackDataBuilder()) { when (type) { - is FeedbackEmailType.DirectUserRequest -> addUserRequestBody(type.cardInfo) - is FeedbackEmailType.RateCanBeBetter -> addCardAndPhoneInfo(type.cardInfo) - is FeedbackEmailType.TransactionSendingProblem -> addTransactionSendingProblemBody(type.cardInfo) + is FeedbackEmailType.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.RateCanBeBetter -> addCardAndPhoneInfo(type.walletMetaInfo) + is FeedbackEmailType.TransactionSendingProblem -> addTransactionSendingProblemBody(type.walletMetaInfo) is FeedbackEmailType.StakingProblem -> addStakingProblemBody(type) is FeedbackEmailType.SwapProblem -> addSwapProblemBody(type) is FeedbackEmailType.CurrencyDescriptionError -> addTokenInfo(type) - is FeedbackEmailType.PreActivatedWallet -> addUserRequestBody(type.cardInfo) + is FeedbackEmailType.PreActivatedWallet -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.ScanningProblem, is FeedbackEmailType.CardAttestationFailed, -> addPhoneInfoBody() - is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.cardInfo) - is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.cardInfo) - is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.cardInfo, type.visaTxDetails) + is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.walletMetaInfo, type.visaTxDetails) } return build() } - private suspend fun FeedbackDataBuilder.addVisaRequestBody(cardInfo: CardInfo, visaTxDetails: VisaTxDetails) { - addUserRequestBody(cardInfo) + private suspend fun FeedbackDataBuilder.addVisaRequestBody( + walletMetaInfo: WalletMetaInfo, + visaTxDetails: VisaTxDetails, + ) { + addUserRequestBody(walletMetaInfo) addDelimiter() addVisaTxInfo(visaTxDetails) } - private suspend fun FeedbackDataBuilder.addUserRequestBody(cardInfo: CardInfo) { - addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(cardInfo.userWalletId)) + private suspend fun FeedbackDataBuilder.addUserRequestBody(walletMetaInfo: WalletMetaInfo) { + addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(walletMetaInfo.userWalletId)) addDelimiter() - addCardInfo(cardInfo) + addUserWalletMetaInfo(walletMetaInfo) addDelimiter() - val userWalletId = cardInfo.userWalletId + val userWalletId = walletMetaInfo.userWalletId if (userWalletId != null) { val blockchainInfoList = feedbackRepository.getBlockchainInfoList(userWalletId) @@ -68,11 +71,11 @@ internal class EmailMessageBodyResolver( addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) } - private suspend fun FeedbackDataBuilder.addTransactionSendingProblemBody(cardInfo: CardInfo) { - addCardInfo(cardInfo) + private suspend fun FeedbackDataBuilder.addTransactionSendingProblemBody(walletMetaInfo: WalletMetaInfo) { + addUserWalletMetaInfo(walletMetaInfo) addDelimiter() - val userWalletId = requireNotNull(cardInfo.userWalletId) { "UserWalletId must be not null" } + val userWalletId = requireNotNull(walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) val blockchainInfo = blockchainError?.let { feedbackRepository.getBlockchainInfo( @@ -91,10 +94,10 @@ internal class EmailMessageBodyResolver( } private suspend fun FeedbackDataBuilder.addStakingProblemBody(type: FeedbackEmailType.StakingProblem) { - addCardInfo(type.cardInfo) + addUserWalletMetaInfo(type.walletMetaInfo) addDelimiter() - val userWalletId = requireNotNull(type.cardInfo.userWalletId) { "UserWalletId must be not null" } + val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) val blockchainInfo = blockchainError?.let { feedbackRepository.getBlockchainInfo( @@ -120,10 +123,10 @@ internal class EmailMessageBodyResolver( } private suspend fun FeedbackDataBuilder.addSwapProblemBody(type: FeedbackEmailType.SwapProblem) { - addCardInfo(type.cardInfo) + addUserWalletMetaInfo(type.walletMetaInfo) addDelimiter() - val userWalletId = requireNotNull(type.cardInfo.userWalletId) { "UserWalletId must be not null" } + val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) val blockchainInfo = blockchainError?.let { feedbackRepository.getBlockchainInfo( @@ -144,8 +147,8 @@ internal class EmailMessageBodyResolver( addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) } - private fun FeedbackDataBuilder.addCardAndPhoneInfo(cardInfo: CardInfo) { - addCardInfo(cardInfo) + private fun FeedbackDataBuilder.addCardAndPhoneInfo(walletMetaInfo: WalletMetaInfo) { + addUserWalletMetaInfo(walletMetaInfo) addDelimiter() addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo()) } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index 849d1ce0c4..490c6611dd 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -18,7 +18,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { fun resolve(type: FeedbackEmailType): String { return when (type) { is FeedbackEmailType.DirectUserRequest -> { - if (type.cardInfo.isStart2Coin) { + if (type.walletMetaInfo.isStart2Coin == true) { resources.getStringSafe(R.string.feedback_subject_support) } else { resources.getStringSafe(R.string.feedback_subject_support_tangem) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index e02206355e..cf7047bb68 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -11,16 +11,15 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.card.common.TapWorkarounds.isVisa -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.feedback.models.CardInfo +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.redux.LegacyAction import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.component.DetailsComponent @@ -56,7 +55,7 @@ internal class DetailsModel @Inject constructor( paramsContainer: ParamsContainer, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val appStateHolder: ReduxStateHolder, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getWalletsUseCase: GetWalletsUseCase, private val feedbackFeatureToggles: FeedbackFeatureToggles, @@ -121,20 +120,15 @@ internal class DetailsModel @Inject constructor( val selectedUserWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") - if (selectedUserWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Send feedback - } - - val scanResponse = selectedUserWallet.requireColdWallet().scanResponse - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + val metaInfo = getWalletMetaInfoUseCase(selectedUserWallet.walletId).getOrNull() ?: return@launch val feedbackType = when { userWallets.all { it is UserWallet.Cold && it.scanResponse.card.isVisa } -> - FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + FeedbackEmailType.Visa.DirectUserRequest(metaInfo) userWallets.all { it !is UserWallet.Cold || it.scanResponse.card.isVisa.not() } -> - FeedbackEmailType.DirectUserRequest(cardInfo) + FeedbackEmailType.DirectUserRequest(metaInfo) else -> { - showFeedbackEmailTypeOptionBS(cardInfo) + showFeedbackEmailTypeOptionBS(metaInfo) return@launch } } @@ -144,18 +138,14 @@ internal class DetailsModel @Inject constructor( } private fun openUseDesk() { - val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") - - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] UseDesk + modelScope.launch { + val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") + val metaInfo = getWalletMetaInfoUseCase.invoke(userWallet.walletId).getOrNull() ?: return@launch + router.push(AppRoute.Usedesk(metaInfo)) } - - val cardInfo = getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - - router.push(AppRoute.Usedesk(cardInfo)) } - private fun showFeedbackEmailTypeOptionBS(selectedCardInfo: CardInfo) { + private fun showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo: WalletMetaInfo) { state.update { it.copy( selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig( @@ -171,7 +161,7 @@ internal class DetailsModel @Inject constructor( content = SelectEmailFeedbackTypeBS( onOptionClick = { option -> onEmailFeedbackTypeOptionSelected( - selectedCardInfo = selectedCardInfo, + selectedWalletMetaInfo = selectedWalletMetaInfo, option = option, ) @@ -189,32 +179,33 @@ internal class DetailsModel @Inject constructor( } private fun onEmailFeedbackTypeOptionSelected( - selectedCardInfo: CardInfo, + selectedWalletMetaInfo: WalletMetaInfo, option: SelectEmailFeedbackTypeBS.Option, ) { modelScope.launch { val feedbackType = when (option) { SelectEmailFeedbackTypeBS.Option.General -> { - if (selectedCardInfo.isVisa.not()) { - FeedbackEmailType.DirectUserRequest(selectedCardInfo) + if (selectedWalletMetaInfo.isVisa == false) { + FeedbackEmailType.DirectUserRequest(selectedWalletMetaInfo) } else { - val scanResponse = getWalletsUseCase.invokeSync() - .firstOrNull { it is UserWallet.Cold && it.scanResponse.card.isVisa.not() } - ?.requireColdWallet()?.scanResponse ?: return@launch + val userWallet = getWalletsUseCase.invokeSync() + .firstOrNull { + it is UserWallet.Hot || it is UserWallet.Cold && it.scanResponse.card.isVisa.not() + } ?: return@launch - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - FeedbackEmailType.DirectUserRequest(cardInfo) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + FeedbackEmailType.DirectUserRequest(metaInfo) } } SelectEmailFeedbackTypeBS.Option.Visa -> { - if (selectedCardInfo.isVisa) { - FeedbackEmailType.Visa.DirectUserRequest(selectedCardInfo) + if (selectedWalletMetaInfo.isVisa == true) { + FeedbackEmailType.Visa.DirectUserRequest(selectedWalletMetaInfo) } else { - val scanResponse = getWalletsUseCase.invokeSync() + val userWallet = getWalletsUseCase.invokeSync() .firstOrNull { it is UserWallet.Cold && it.scanResponse.card.isVisa } - ?.requireColdWallet()?.scanResponse ?: return@launch - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + ?: return@launch + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + FeedbackEmailType.Visa.DirectUserRequest(metaInfo) } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt index a864a30018..1567ffe625 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt @@ -9,7 +9,7 @@ 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.card.repository.CardRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse @@ -39,7 +39,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val cardRepository: CardRepository, private val analyticsHandler: AnalyticsEventHandler, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, @@ -168,7 +168,8 @@ internal class MultiWalletCreateWalletModel @Inject constructor( fun navigateToSupportScreen() { modelScope.launch { - val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 0c7b8d6290..1e9e8c838e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -9,7 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.CardDTO @@ -48,7 +48,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val backupServiceHolder: BackupServiceHolder, private val tangemSdkManager: TangemSdkManager, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, private val userWalletsListManager: UserWalletsListManager, @@ -329,7 +329,8 @@ internal class MultiWalletFinalizeModel @Inject constructor( private fun navigateToSupportScreen() { modelScope.launch { - val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt index c59be4fb41..2bdce1a527 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt @@ -10,7 +10,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.crypto.bip39.Mnemonic import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.features.hotwallet.MnemonicRepository @@ -44,7 +44,7 @@ internal class MultiWalletSeedPhraseModel @Inject constructor( private val urlOpener: UrlOpener, private val tangemSdkManager: TangemSdkManager, private val cardRepository: CardRepository, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val analyticsHandler: AnalyticsEventHandler, ) : Model() { @@ -257,7 +257,8 @@ internal class MultiWalletSeedPhraseModel @Inject constructor( fun navigateToSupportScreen() { modelScope.launch { - val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt index 6a15db448c..af0fadb5fb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt @@ -10,7 +10,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.decompose.context.AppComponentContext import com.tangem.domain.card.common.TapWorkarounds.isVisa -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent @@ -24,7 +24,7 @@ import kotlinx.coroutines.launch internal class DefaultOnboardingStepperComponent @AssistedInject constructor( @Assisted val context: AppComponentContext, @Assisted val params: OnboardingStepperComponent.Params, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val analyticsHandler: AnalyticsEventHandler, ) : OnboardingStepperComponent, AppComponentContext by context { @@ -40,7 +40,7 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor( ) componentScope.launch { - val cardInfo = getCardInfoUseCase(params.scanResponse).getOrNull() ?: return@launch + val cardInfo = getWalletMetaInfoUseCase(params.scanResponse).getOrNull() ?: return@launch sendFeedbackEmailUseCase( if (params.scanResponse.card.isVisa) { FeedbackEmailType.Visa.Activation(cardInfo) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index 5fcc8c9505..27f86bfa82 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -20,14 +20,12 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase @@ -90,7 +88,7 @@ internal class SendConfirmModel @Inject constructor( private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, @@ -340,15 +338,9 @@ internal class SendConfirmModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase.invoke(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index f0a63844c1..e47d5db337 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -18,7 +18,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -26,7 +26,6 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase @@ -91,7 +90,7 @@ internal class SendModel @Inject constructor( private val parseQrCodeUseCase: ParseQrCodeUseCase, private val sendConfirmAlertFactory: SendConfirmAlertFactory, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, @@ -472,15 +471,9 @@ internal class SendModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index 0cd5ed9676..8b9888ff75 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -16,13 +16,11 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase @@ -78,7 +76,7 @@ internal class NFTSendConfirmModel @Inject constructor( private val sendTransactionUseCase: SendTransactionUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger, private val notificationsUpdateListener: SendNotificationsUpdateListener, @@ -249,15 +247,9 @@ internal class NFTSendConfirmModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt index 11d9d7ba78..6f1080a389 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -14,7 +14,7 @@ import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -22,7 +22,6 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase @@ -71,7 +70,7 @@ internal class NFTSendModel @Inject constructor( private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val alertFactory: SendConfirmAlertFactory, private val sendFeatureToggles: SendFeatureToggles, @@ -224,15 +223,9 @@ internal class NFTSendModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo)) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 31e0e9e58c..3fa6edebdd 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -23,7 +23,7 @@ import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -34,7 +34,6 @@ import com.tangem.domain.models.staking.* import com.tangem.domain.models.staking.action.StakingActionType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.staking.* import com.tangem.domain.staking.analytics.StakeScreenSource import com.tangem.domain.staking.analytics.StakingAnalyticsEvent @@ -107,7 +106,7 @@ internal class StakingModel @Inject constructor( private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val getAllowanceUseCase: GetAllowanceUseCase, private val vibratorHapticManager: VibratorHapticManager, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, @@ -859,13 +858,8 @@ internal class StakingModel @Inject constructor( modelScope.launch { val network = cryptoCurrencyStatus.currency.network - if (userWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse) - .getOrElse { error("CardInfo must be not null") } + val metaInfo = + getWalletMetaInfoUseCase(userWallet.walletId).getOrElse { error("CardInfo must be not null") } val amountState = uiState.value.amountState as? AmountState.Data val confirmationState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data val validatorState = uiState.value.validatorState as? StakingStates.ValidatorState.Data @@ -887,7 +881,7 @@ internal class StakingModel @Inject constructor( ) val email = FeedbackEmailType.StakingProblem( - cardInfo = cardInfo, + walletMetaInfo = metaInfo, validatorName = validator?.name, transactionTypes = transactionsInProgress.map { it.type.name }, unsignedTransactions = transactionsInProgress.map { it.unsignedTransaction }, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt index 9ea883b4e5..d5360e2add 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt @@ -8,14 +8,13 @@ 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.express.models.ExpressError -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.features.swap.v2.impl.R import javax.inject.Inject @@ -24,7 +23,7 @@ import javax.inject.Inject internal class SwapAlertFactory @Inject constructor( private val uiMessageSender: UiMessageSender, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, ) { fun getGenericErrorState(expressError: ExpressError, onFailedTxEmailClick: () -> Unit, popBack: () -> Unit = {}) { @@ -96,16 +95,11 @@ internal class SwapAlertFactory @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = - getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return sendFeedbackEmailUseCase( type = FeedbackEmailType.SwapProblem( - cardInfo = cardInfo, + walletMetaInfo = metaInfo, providerName = confirmData?.quote?.provider?.name.orEmpty(), txId = txId.orEmpty(), ), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 2e8026f8db..bc6725502d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -23,7 +23,7 @@ import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo @@ -33,7 +33,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.promo.ShouldShowStoriesUseCase import com.tangem.domain.promo.models.StoryContentIds @@ -94,7 +93,7 @@ internal class SwapModel @Inject constructor( private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, @@ -1364,15 +1363,11 @@ internal class SwapModel @Inject constructor( ), ) - if (userWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val cardInfo = getCardInfoUseCase(userWallet.requireColdWallet().scanResponse) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId) .getOrElse { error("CardInfo must be not null") } val email = FeedbackEmailType.SwapProblem( - cardInfo = cardInfo, + walletMetaInfo = metaInfo, providerName = dataState.selectedProvider?.name.orEmpty(), txId = transaction?.txId.orEmpty(), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt index 4a492e3a06..0c9b0a698c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt @@ -4,7 +4,7 @@ import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.visa.GetVisaCurrencyUseCase @@ -39,7 +39,7 @@ internal class VisaWalletIntentsImplementor @Inject constructor( private val eventSender: WalletEventSender, private val getVisaCurrencyUseCase: GetVisaCurrencyUseCase, private val getVisaTxDetailsUseCase: GetVisaTxDetailsUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val getUserWalletsUseCase: GetWalletsUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val dispatchers: CoroutineDispatcherProvider, @@ -101,13 +101,13 @@ internal class VisaWalletIntentsImplementor @Inject constructor( val userWalletId = stateController.getSelectedWalletId() val userWallet = getUserWalletsUseCase.invokeSync() .firstOrNull { it.walletId == userWalletId } ?: return@launch - val cardInfo = getCardInfoUseCase.invoke( + val cardInfo = getWalletMetaInfoUseCase.invoke( userWallet.requireColdWallet().scanResponse, ).getOrNull() ?: return@launch sendFeedbackEmailUseCase( FeedbackEmailType.Visa.Dispute( - cardInfo = cardInfo, + walletMetaInfo = cardInfo, visaTxDetails = txDetails, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 5e3757b744..7fae09097d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -11,7 +11,7 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.domain.card.SetCardWasScannedUseCase -import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency @@ -107,7 +107,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val dispatchers: CoroutineDispatcherProvider, private val shouldShowPromoWalletUseCase: ShouldShowPromoWalletUseCase, - private val getCardInfoUseCase: GetCardInfoUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val seedPhraseNotificationUseCase: SeedPhraseNotificationUseCase, private val urlOpener: UrlOpener, @@ -244,15 +244,9 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( neverToSuggestRateAppUseCase() val userWallet = getSelectedUserWallet() ?: return@launch + val cardInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch - if (userWallet is UserWallet.Hot) { - return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val scanResponse = userWallet.requireColdWallet().scanResponse - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - - sendFeedbackEmailUseCase(type = FeedbackEmailType.RateCanBeBetter(cardInfo = cardInfo)) + sendFeedbackEmailUseCase(type = FeedbackEmailType.RateCanBeBetter(walletMetaInfo = cardInfo)) } } @@ -292,15 +286,9 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( override fun onSupportClick() { val userWallet = getSelectedUserWallet() ?: return - if (userWallet is UserWallet.Hot) { - return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow - } - - val scanResponse = userWallet.requireColdWallet().scanResponse - val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return - modelScope.launch { - sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(cardInfo = cardInfo)) + val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(walletMetaInfo = metaInfo)) } } From 2934b42af5fd5548760d0a209b6c00598ba45743 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 2 Sep 2025 12:17:12 +0300 Subject: [PATCH 035/132] Updated on 2026-08-14 --- .gitignore | 8 ++ .../tap/di/domain/OnrampDomainModule.kt | 6 ++ .../tangem/tap/routing/utils/ChildFactory.kt | 1 + .../com/tangem/common/routing/AppRoute.kt | 1 + core/res/src/main/res/values-es/strings.xml | 5 +- core/res/src/main/res/values-ja/strings.xml | 8 +- core/res/src/main/res/values-ru/strings.xml | 21 ++++- .../src/main/res/values-uk-rUA/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 2 +- .../res/drawable/img_notification_sepa.webp | Bin 0 -> 14202 bytes .../tangem/utils/coroutines/CoroutineExt.kt | 23 +++++ .../data/onramp/DefaultOnrampRepository.kt | 45 ++++++++++ .../data/promo/DefaultPromoRepository.kt | 17 ++-- .../domain/onramp/model/OnrampSource.kt | 1 + .../onramp/OnrampSepaAvailableUseCase.kt | 80 ++++++++++++++++++ .../onramp/repositories/OnrampRepository.kt | 6 ++ .../tangem/domain/promo/models/PromoBanner.kt | 1 + .../analytics/TokenSwapPromoAnalyticsEvent.kt | 13 +-- .../onramp/component/OnrampComponent.kt | 7 +- .../onramp/main/OnrampMainComponent.kt | 1 + .../main/entity/factory/OnrampStateFactory.kt | 4 + .../main/model/OnrampMainComponentModel.kt | 25 +++++- .../onramp/root/DefaultOnrampComponent.kt | 1 + ...okenDetailsNotificationsAnalyticsSender.kt | 2 +- .../tokendetails/model/TokenDetailsModel.kt | 4 +- .../intents/WalletWarningsClickIntents.kt | 45 +++++++--- .../utils/WalletWarningsAnalyticsSender.kt | 8 +- .../domain/GetMultiWalletWarningsFactory.kt | 56 ++++++++++-- .../wallet/state/model/WalletNotification.kt | 17 ++++ 29 files changed, 360 insertions(+), 49 deletions(-) create mode 100644 core/ui/src/main/res/drawable/img_notification_sepa.webp create mode 100644 domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampSepaAvailableUseCase.kt diff --git a/.gitignore b/.gitignore index 0144ddc6e7..a4d9c4c73d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,14 @@ # Built application files /build /buildSrc +**/build/.transforms/** +**/build/classes/** +**/build/generated/** +**/build/intermediates/** +**/build/kotlin/** +**/build/libs/** +**/build/outputs/** +**/build/tmp/** # Local configuration file (sdk path, etc) local.properties diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index 335ce4196c..f5c4fda538 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -120,6 +120,12 @@ internal object OnrampDomainModule { return OnrampSaveTransactionUseCase(onrampTransactionRepository, onrampErrorResolver) } + @Provides + @Singleton + fun provideOnrampSepaAvailableUseCase(onrampRepository: OnrampRepository): OnrampSepaAvailableUseCase { + return OnrampSepaAvailableUseCase(onrampRepository) + } + @Provides @Singleton fun provideOnrampUpdateTransactionStatusUseCase( 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 9a80508e7b..708e4bc745 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 @@ -207,6 +207,7 @@ internal class ChildFactory @Inject constructor( userWalletId = route.userWalletId, cryptoCurrency = route.currency, source = route.source, + launchSepa = route.launchSepa, ), componentFactory = onrampComponentFactory, ) 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 20d91d8960..d2204b5cd1 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 @@ -238,6 +238,7 @@ sealed class AppRoute(val path: String) : Route { val source: OnrampSource, val userWalletId: UserWalletId, val currency: CryptoCurrency, + val launchSepa: Boolean = false, ) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) } diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index c70f5846b2..170d0a6610 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -878,7 +878,7 @@ Memo Compruebe su conexión de red Información de tarifa de red no accesible - Tú envías + Envía Desde De %s Límite de gas @@ -951,10 +951,13 @@ Intercambiar y enviar ¿Continuar con la conversión? Esto borrará sus datos anteriores. Confirmar Conversión + El envío de cualquier otra moneda supondrá su pérdida irreversible. + Seleccione la red de destino correcta Envía cualquier token y lo convertiremos en el camino. Su destinatario obtiene exactamente lo que necesita, sin problemas. El destinatario recibirá Al destinatario Cantidad a recibir + El destinatario recibe %s ¿Seguro que desea cancelar la conversión? Se borrarán sus datos anteriores. Enviar con swap Transacción enviada diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 76ddd053e5..ff93a67733 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1043,13 +1043,13 @@ 受信トークンを変更してもよろしいですか? 変更すると、以前入力したデータがリセットされます。 トークンの変更 スワップして送信 - 変換を続行しますか? これにより以前のデータは消去されます。 + スワップを実行しますか?これにより以前のデータは消去されます。 変換を確定 その他の通貨を送信すると、取り返しのつかない損失が発生します。 正しい受信者ネットワークを選択してください - 受け取るトークンを自由に選んでください。受信者は、あなたが選択したトークンをシームレスに受け取ります。 - 受信者は受け取ります - 受取人へ + 受け取るトークンを選んでください。受信者は、あなたが選択したトークンをシームレスに受け取ります。 + 受信者が受け取ります + 受信者へ 受取金額 受信者は%sを取得します 変換をキャンセルしてもよろしいですか?以前のデータは消去されます。 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 9fda11100a..f21a1bdd98 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1,5 +1,11 @@ + Архив + Вы архивируете свой аккаунт, но в любое время можете вернуть его обратно + Аккаунт + Новый аккаунт + Продолжить редактирование + Несохраненные изменения Не нашли свой токен? Перейдите в раздел «Рынок» на главной странице и добавьте его в свой портфель для покупки. Не нашли свой токен? Перейдите в раздел «Рынок» на главной странице и добавьте его в свой портфель для продажи. Продать @@ -309,6 +315,7 @@ Legacy адрес Получить активы Отправка средств в другой сети может повлечь потерю средств. + %s сети Отправляйте средства, используя только Привет, команда поддержки, у меня возникла ошибка с кодом: %s Ошибка WalletConnect @@ -410,7 +417,16 @@ Сканировать в %s В сети %s + Вы уверены, что хотите прервать процесс создания кода доступа? + Аппаратный кошелёк Будьте в курсе новых функций и новостей + Это устройство не может быть использовано для апгрейда, оно уже содержит другой кошелек. + Во время операции произошла ошибка. + Приватные ключи будут перемещены из приложения в вашу Tangem карту или кольцо + Миграция ключей + Начать апгрейд + Tangem кошелек + Сделать апгрейд кошелька до аппаратной версии. Эта информация была сгенерирована ИИ.\nНажмите здесь, если обнаружили ошибку. Чтобы изменить код доступа, приложите карту или кольцо как показано выше и не убирайте до окончания операции Чтобы изменить пароль, приложите карту как показано выше и не убирайте до окончания операции @@ -928,7 +944,7 @@ Вы уверены, что хотите изменить токен для получения? Это действие сбросит ранее введённые данные. Изменение токена Обмен и отправка - Продолжить с конвертацией? Это действие удалит предыдущие данные + Продолжить с обменом? Это действие удалит предыдущие данные Подтвердить конвертацию Выберите любой токен к получению. Ваш адресат получит ровно то, что вы выбрали — без лишних сложностей. Будет получено @@ -1100,6 +1116,7 @@ Ошибка расчета комиссии. Пожалуйста, отправьте информацию в поддержку. Вы отправляете Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму. + Высокое влияние на цену Недостаточно средств Дать разрешение Обменять @@ -1228,9 +1245,11 @@ Получить с 10% скидкой Получите доступ к более чем 13 000 криптовалют. Покупайте, продавайте, обменивайте и стейкайте в один клик. Свяжите до трех карт для резервного копирования. Откройте Tangem Wallet + Изменить Код Доступа Получайте уведомления о входящих транзакциях в кошельке и обновлениях Tangem. В настоящий момент push-уведомления могут не работать на устройствах Huawei. Мы уже работаем над решением этой проблемы и планируем исправление в ближайших обновлениях. Спасибо за понимание! Уведомления о транзакциях + Установить Код Доступа Настройки кошелька Tangem Используйте %s или отсканируйте карту/кольцо, чтобы разблокировать доступ к вашему кошельку diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 14ac369e2e..5fa3e94450 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1367,6 +1367,7 @@ Будь ласка, згенеруйте новий URI та спробуйте ще раз Термін для з’єднання минув Прогнозовані зміни + Пропоновано %s Поповніть баланс, щоб покрити комісію мережі Недостатньо %1$s Додайте %s мережі до вашого портфелю для цього гаманця diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 1a6894683e..f24d713213 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1060,7 +1060,7 @@ Are you sure you want to change the receiving token? This will reset your previously entered data. Changing token Swap and send - Proceed with conversion? This will clear your previous data. + Proceed with swap? This will clear your previous data. Confirm Conversion Sending any other currency will result in its irreversible loss. Select the correct recipient network diff --git a/core/ui/src/main/res/drawable/img_notification_sepa.webp b/core/ui/src/main/res/drawable/img_notification_sepa.webp new file mode 100644 index 0000000000000000000000000000000000000000..3ca547ac85f2750c4f514dc03b4342568167571f GIT binary patch literal 14202 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 literal 0 HcmV?d00001 diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt index 901cf071d6..b57c8e0015 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt @@ -1,6 +1,8 @@ package com.tangem.utils.coroutines import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -54,4 +56,25 @@ fun CoroutineScope.launchOnCancellation(block: suspend () -> Unit) { } } } +} + +@Suppress("LongParameterList", "MagicNumber") +inline fun combine6( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6) { arr -> + @Suppress("UNCHECKED_CAST") + transform( + arr[0] as T1, + arr[1] as T2, + arr[2] as T3, + arr[3] as T4, + arr[4] as T5, + arr[5] as T6, + ) } \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index a4216c1fd9..1bbb60d6ea 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -261,6 +261,48 @@ internal class DefaultOnrampRepository( storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await()) } + override suspend fun hasMercuryoSepaMethod( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ): Boolean { + return withContext(dispatchers.io) { + val onrampPairs = + safeApiCall( + call = { + onrampApi.getPairs( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + body = OnrampPairsRequest( + fromCurrencyCode = currency.code, + countryCode = country.code, + to = listOf( + OnrampDestinationDTO( + contractAddress = cryptoCurrency.getContractAddress(), + network = cryptoCurrency.network.backendId, + ), + ), + ), + ).bind() + }, + onError = { + Timber.w(it, "Unable to fetch onramp pairs") + throw it + }, + ) + + val mercuryoProvider = onrampPairs.map { it.providers }.flatten() + .find { it.providerId == MERCURYO_PROVIDER_ID } + val hasSepaMethod = mercuryoProvider?.paymentMethods?.any { it == SEPA_METHOD_ID } ?: false + + hasSepaMethod + } + } + override suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) = withContext(dispatchers.io) { val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) { @@ -554,5 +596,8 @@ internal class DefaultOnrampRepository( const val PROVIDER_THEME_LIGHT = "light" const val REDIRECT_URL = "https://tangem.com/onramp" + + const val SEPA_METHOD_ID = "sepa" + const val MERCURYO_PROVIDER_ID = "mercuryo" } } \ No newline at end of file diff --git a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt index efdaae18b9..f58b3d0613 100644 --- a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt +++ b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt @@ -38,24 +38,19 @@ internal class DefaultPromoRepository( key = PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), default = true, ).map { shouldShow -> - if (promoId == PromoId.Referral) { - runCatching { + when (promoId) { + PromoId.Referral -> runCatching { !referralRepository.isReferralParticipant(userWalletId) && shouldShow }.getOrDefault(false) - } else { - shouldShow + PromoId.Sepa -> shouldShow } } } override fun isReadyToShowTokenPromo(promoId: PromoId): Flow { - return if (promoId == PromoId.Referral) { - flowOf(false) - } else { - appPreferencesStore.get( - PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), - default = false, - ) + return when (promoId) { + PromoId.Referral -> flowOf(false) + PromoId.Sepa -> flowOf(false) } } diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt index 86b72907ad..e193dbc3b2 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt @@ -6,4 +6,5 @@ enum class OnrampSource(val analyticsName: String) { TOKEN_LONG_TAP("Long Tap"), TOKEN_DETAILS("Token"), MARKETS("Markets"), + SEPA_BANNER("SEPA Banner"), } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampSepaAvailableUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampSepaAvailableUseCase.kt new file mode 100644 index 0000000000..fb43156695 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampSepaAvailableUseCase.kt @@ -0,0 +1,80 @@ +package com.tangem.domain.onramp + +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampCountry +import com.tangem.domain.onramp.model.OnrampCurrency + +class OnrampSepaAvailableUseCase( + private val repository: OnrampRepository, +) { + + suspend operator fun invoke( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ): Boolean { + if (country.code !in SEPA_AVAILABLE_COUNTRY_CODES) { + return false + } + + return Either.catch { + repository.hasMercuryoSepaMethod( + userWallet = userWallet, + currency = currency, + country = country, + cryptoCurrency = cryptoCurrency, + ) + }.getOrElse { false } + } + + companion object { + val SEPA_AVAILABLE_COUNTRY_CODES = listOf( + "AL", // Albania + "AD", // Andorra + "AT", // Austria + "BE", // Belgium + "BG", // Bulgaria + "HR", // Croatia + "CY", // Cyprus + "CZ", // Czech Republic + "DK", // Denmark + "EE", // Estonia + "FI", // Finland + "FR", // France + "DE", // Germany + "GR", // Greece + "HU", // Hungary + "IS", // Iceland + "IE", // Ireland + "IT", // Italy + "LV", // Latvia + "LI", // Liechtenstein + "LT", // Lithuania + "LU", // Luxembourg + "MT", // Malta + "MD", // Moldova + "MC", // Monaco + "ME", // Montenegro + "NL", // Netherlands + "MK", // North Macedonia + "NO", // Norway + "PL", // Poland + "PT", // Portugal + "RO", // Romania + "SM", // San Marino + "RS", // Serbia + "SK", // Slovakia + "SI", // Slovenia + "ES", // Spain + "SE", // Sweden + "CH", // Switzerland + "GB", // United Kingdom + "VA", // Vatican City + ) + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt index b4879d8cc7..0a7ad02041 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampRepository.kt @@ -15,6 +15,12 @@ interface OnrampRepository { suspend fun getCountriesSync(): List? suspend fun getCountryByIp(userWallet: UserWallet): OnrampCountry suspend fun getStatus(userWallet: UserWallet, txId: String): OnrampStatus + suspend fun hasMercuryoSepaMethod( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ): Boolean suspend fun fetchCurrencies(userWallet: UserWallet) suspend fun fetchCountries(userWallet: UserWallet): List suspend fun fetchPaymentMethodsIfAbsent(userWallet: UserWallet) diff --git a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt index 81f592f146..7605016350 100644 --- a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt +++ b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/PromoBanner.kt @@ -27,4 +27,5 @@ data class PromoBanner( enum class PromoId { Referral, + Sepa, } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt index 7ed6c36cc2..f460d229ff 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt @@ -9,24 +9,24 @@ sealed class TokenSwapPromoAnalyticsEvent( ) : AnalyticsEvent(category = "Promotion", event = event, params = params) { class NoticePromotionBanner( source: AnalyticsParam.ScreensSources, - programName: ProgramName, + program: Program, ) : TokenSwapPromoAnalyticsEvent( event = "Notice - Promotion Banner", params = mapOf( AnalyticsParam.SOURCE to source.value, - "Program Name" to programName.name, + "Program Name" to program.programName, ), ) class PromotionBannerClicked( source: AnalyticsParam.ScreensSources, - programName: ProgramName, + program: Program, action: BannerAction, ) : TokenSwapPromoAnalyticsEvent( event = "Promo Banner Clicked", params = mapOf( AnalyticsParam.SOURCE to source.value, - "Program Name" to programName.name, + "Program Name" to program.programName, "Action" to action.action, ), ) { @@ -37,7 +37,8 @@ sealed class TokenSwapPromoAnalyticsEvent( } // Use it on new promo action - enum class ProgramName { - Empty, + enum class Program(val programName: String) { + Empty("Empty"), + Sepa("Sepa"), } } \ No newline at end of file diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt index 7bdb6d8901..3de1a19fb7 100644 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt @@ -8,7 +8,12 @@ import com.tangem.domain.models.wallet.UserWalletId interface OnrampComponent : ComposableContentComponent { - data class Params(val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val source: OnrampSource) + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val source: OnrampSource, + val launchSepa: Boolean = false, + ) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt index 57c298a510..fa5cf89457 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt @@ -15,6 +15,7 @@ internal interface OnrampMainComponent : ComposableContentComponent { val source: OnrampSource, val openSettings: () -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + val launchSepa: Boolean, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt index 8eb611e8fe..bfd3db959d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt @@ -148,4 +148,8 @@ internal class OnrampStateFactory( decimals = currency.precision, type = AmountType.FiatType(currency.code), ) + + companion object { + const val PREDEFINED_SEPA_AMOUNT = "100" + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 50acb8a5a6..f78daf8e79 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -28,6 +28,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.main.OnrampMainComponent import com.tangem.features.onramp.main.entity.* import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory +import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory.Companion.PREDEFINED_SEPA_AMOUNT import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory import com.tangem.features.onramp.providers.entity.SelectProviderResult import com.tangem.features.onramp.utils.sendOnrampErrorEvent @@ -64,6 +65,8 @@ internal class OnrampMainComponentModel @Inject constructor( private val params: OnrampMainComponent.Params = paramsContainer.require() + private var isSepaLaunched = false + val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val stateFactory = OnrampStateFactory( @@ -148,11 +151,16 @@ internal class OnrampMainComponentModel @Inject constructor( if (country == null) return@onEach _state.update { if (it is OnrampMainComponentUM.InitialLoading) { - stateFactory.getReadyState(country.defaultCurrency) + val state = stateFactory.getReadyState(country.defaultCurrency) + if (params.launchSepa) { + onAmountValueChanged(PREDEFINED_SEPA_AMOUNT) + } + state } else { amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } } + updatePairsAndQuotes() }, ) @@ -317,8 +325,18 @@ internal class OnrampMainComponentModel @Inject constructor( private fun selectOrUpdateQuote(quotes: List): OnrampQuote? { val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error } + val sepaQuote = if (params.launchSepa && !isSepaLaunched) { + isSepaLaunched = true + + quotes.filterIsInstance().firstOrNull { + it.provider.id == MERCURYO_PROVIDER_ID && it.paymentMethod.id == SEPA_METHOD_ID + } + } else { + null + } + // Check if amount, country or currency has changed - val newQuote = if (checkLastInputState(quoteToCheck)) { + val newQuote = sepaQuote ?: if (checkLastInputState(quoteToCheck)) { quoteToCheck } else { val state = state.value as? OnrampMainComponentUM.Content @@ -420,5 +438,8 @@ internal class OnrampMainComponentModel @Inject constructor( private companion object { const val UPDATE_DELAY = 10_000L + + const val MERCURYO_PROVIDER_ID = "mercuryo" + const val SEPA_METHOD_ID = "sepa" } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt index e08b4a4477..e9ee2b3295 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt @@ -81,6 +81,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor( ), ) }, + launchSepa = params.launchSepa, ), ) is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index dbe31e24b9..c02b4801b9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -38,7 +38,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( currency = cryptoCurrency, ) is TokenDetailsNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner( - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Empty, // Use it on new promo action source = AnalyticsParam.ScreensSources.Token, ) is TokenDetailsNotification.KaspaIncompleteTransactionWarning -> TokenDetailsAnalyticsEvent.Notice.Reveal( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index d0e4d0f29c..795dbe542e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -850,7 +850,7 @@ internal class TokenDetailsModel @Inject constructor( analyticsEventsHandler.send( TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( source = AnalyticsParam.ScreensSources.Token, - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Empty, // Use it on new promo action action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Closed, ), ) @@ -863,7 +863,7 @@ internal class TokenDetailsModel @Inject constructor( analyticsEventsHandler.send( TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( source = AnalyticsParam.ScreensSources.Token, - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Empty, // Use it on new promo action action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Clicked, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 7fae09097d..28d03a317a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -19,6 +19,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher @@ -75,7 +76,7 @@ internal interface WalletWarningsClickIntents { fun onClosePromoClick(promoId: PromoId) - fun onPromoClick(promoId: PromoId) + fun onPromoClick(promoId: PromoId, cryptoCurrency: CryptoCurrency? = null) fun onSupportClick() @@ -92,7 +93,7 @@ internal interface WalletWarningsClickIntents { fun onFinishWalletActivationClick() } -@Suppress("LongParameterList") +@Suppress("LargeClass", "LongParameterList") @ModelScoped internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val stateHolder: WalletStateController, @@ -260,26 +261,46 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( override fun onClosePromoClick(promoId: PromoId) { analyticsEventHandler.send( - if (promoId == PromoId.Referral) { - MainScreen.ReferralPromoButtonDismiss - } else { - TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( + when (promoId) { + PromoId.Referral -> MainScreen.ReferralPromoButtonDismiss + PromoId.Sepa -> TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( source = AnalyticsParam.ScreensSources.Main, - programName = TokenSwapPromoAnalyticsEvent.ProgramName.Empty, // Use it on new promo action + program = TokenSwapPromoAnalyticsEvent.Program.Sepa, action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Closed, ) }, + ) modelScope.launch(dispatchers.main) { shouldShowPromoWalletUseCase.neverToShow(promoId) } } - override fun onPromoClick(promoId: PromoId) { - if (promoId == PromoId.Referral) { - val userWallet = getSelectedUserWallet() ?: return - analyticsEventHandler.send(MainScreen.ReferralPromoButtonParticipate) - appRouter.push(AppRoute.ReferralProgram(userWalletId = userWallet.walletId)) + override fun onPromoClick(promoId: PromoId, cryptoCurrency: CryptoCurrency?) { + val userWallet = getSelectedUserWallet() ?: return + when (promoId) { + PromoId.Referral -> { + analyticsEventHandler.send(MainScreen.ReferralPromoButtonParticipate) + appRouter.push(AppRoute.ReferralProgram(userWalletId = userWallet.walletId)) + } + PromoId.Sepa -> { + analyticsEventHandler.send( + TokenSwapPromoAnalyticsEvent.PromotionBannerClicked( + source = AnalyticsParam.ScreensSources.Main, + program = TokenSwapPromoAnalyticsEvent.Program.Sepa, + action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Clicked, + ), + ) + cryptoCurrency ?: return + appRouter.push( + AppRoute.Onramp( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + source = OnrampSource.SEPA_BANNER, + launchSepa = true, + ), + ) + } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt index d75237b768..bd539e7397 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt @@ -5,7 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent -import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent.ProgramName +import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent.Program import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState @@ -51,7 +51,11 @@ internal class WalletWarningsAnalyticsSender @Inject constructor( is WalletNotification.NoteMigration -> MainScreen.NotePromo is WalletNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner( source = AnalyticsParam.ScreensSources.Main, - programName = ProgramName.Empty, // Use it on new promo action + program = Program.Empty, // Use it on new promo action + ) + is WalletNotification.Sepa -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner( + source = AnalyticsParam.ScreensSources.Main, + program = Program.Sepa, ) is WalletNotification.ReferralPromo -> MainScreen.ReferralPromo is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender] diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index a7e173397b..5bd5ebfce1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -1,5 +1,8 @@ +@file:Suppress("MaximumLineLength") + package com.tangem.feature.wallet.presentation.wallet.domain +import arrow.core.getOrElse import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint @@ -14,9 +17,12 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.GetOnrampCountryUseCase +import com.tangem.domain.onramp.OnrampSepaAvailableUseCase import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.settings.IsReadyToShowRateAppUseCase +import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase @@ -24,11 +30,13 @@ import com.tangem.domain.wallets.usecase.SeedPhraseNotificationUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification +import com.tangem.lib.crypto.BlockchainUtils.isBitcoin +import com.tangem.utils.coroutines.combine6 import com.tangem.utils.extensions.isPositive import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine +import timber.log.Timber import javax.inject.Inject @Suppress("LongParameterList") @@ -41,19 +49,22 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private val backupValidator: BackupValidator, private val seedPhraseNotificationUseCase: SeedPhraseNotificationUseCase, private val shouldShowPromoWalletUseCase: ShouldShowPromoWalletUseCase, + private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, + private val onrampSepaAvailableUseCase: OnrampSepaAvailableUseCase, + private val getOnrampCountryUseCase: GetOnrampCountryUseCase, ) { - @Suppress("MagicNumber", "MaximumLineLength") fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow> { val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver - return combine( - flow = tokenListStore.getOrThrow(userWallet.walletId), + return combine6( + flow1 = tokenListStore.getOrThrow(userWallet.walletId), flow2 = isReadyToShowRateAppUseCase(), flow3 = isNeedToBackupUseCase(userWallet.walletId), flow4 = seedPhraseNotificationUseCase(userWalletId = userWallet.walletId), flow5 = shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Referral), - ) { maybeTokenList, isReadyToShowRating, isNeedToBackup, seedPhraseIssueStatus, shouldShowReferralPromo -> + flow6 = shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Sepa), + ) { maybeTokenList, isReadyToShowRating, isNeedToBackup, seedPhraseIssueStatus, shouldShowReferralPromo, shouldShowSepaBanner -> buildList { addUsedOutdatedDataNotification(maybeTokenList) @@ -63,6 +74,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) + addSepaPromoNotification(userWallet, clickIntents, shouldShowSepaBanner) + addInformationalNotifications(cardTypesResolver, maybeTokenList, clickIntents) addWarningNotifications(cardTypesResolver, maybeTokenList, isNeedToBackup, clickIntents) @@ -217,6 +230,39 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( ) } + private suspend fun MutableList.addSepaPromoNotification( + userWallet: UserWallet, + clickIntents: WalletClickIntents, + shouldShowSepaPromo: Boolean, + ) { + val currencies = getCryptoCurrenciesUseCase(userWalletId = userWallet.walletId).getOrElse { + Timber.e("Error on getting crypto currency list") + return + } + + val bitcoinCurrency = currencies.find { isBitcoin(it.network.rawId) } ?: return + + val country = getOnrampCountryUseCase.invokeSync(userWallet).getOrElse { + Timber.e("Error on getting onramp country") + return + } + + val isSepaAvailable = onrampSepaAvailableUseCase( + userWallet = userWallet, + country = country, + currency = country.defaultCurrency, + cryptoCurrency = bitcoinCurrency, + ) + + addIf( + element = WalletNotification.Sepa( + onCloseClick = { clickIntents.onClosePromoClick(promoId = PromoId.Sepa) }, + onClick = { clickIntents.onPromoClick(promoId = PromoId.Sepa, bitcoinCurrency) }, + ), + condition = shouldShowSepaPromo && isSepaAvailable, + ) + } + private fun MutableList.addWarningNotifications( cardTypesResolver: CardTypesResolver?, tokenList: Lce, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index dd6ae1ec2b..f1459ac5d1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -287,4 +287,21 @@ sealed class WalletNotification(val config: NotificationConfig) { iconSize = 54.dp, ), ) + + data class Sepa( + val onCloseClick: () -> Unit, + val onClick: () -> Unit, + ) : WalletNotification( + config = NotificationConfig( + title = resourceReference(R.string.notification_sepa_title), + subtitle = resourceReference(R.string.notification_sepa_text), + iconResId = R.drawable.img_notification_sepa, + onCloseClick = onCloseClick, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.notification_sepa_button), + onClick = onClick, + ), + iconSize = 54.dp, + ), + ) } \ No newline at end of file From f466395589df2b7364316453e4bbdf40be4d5499 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 20:22:30 +0400 Subject: [PATCH 036/132] Updated on 2026-08-14 --- .../main/assets/configs/feature_toggles_config.json | 4 ++++ data/account/build.gradle.kts | 11 +++++++++++ .../com/tangem/data/account/di/AccountDataModule.kt | 9 +++++++++ .../featuretoggle/DefaultAccountsFeatureToggles.kt | 12 ++++++++++++ .../account/featuretoggle/AccountsFeatureToggles.kt | 11 +++++++++++ gradle/tangem_dependencies.toml | 2 +- 6 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/featuretoggle/DefaultAccountsFeatureToggles.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/featuretoggle/AccountsFeatureToggles.kt 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 9d99dceed5..3cf7a9e285 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 @@ -70,5 +70,9 @@ { "name": "NEW_ONRAMP_MAIN_ENABLED", "version": "undefined" + }, + { + "name": "ACCOUNTS_FEATURE_ENABLED", + "version": "undefined" } ] diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 90800c1228..ace90489e6 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { // region Project - Core implementation(projects.core.datasource) + implementation(projects.core.configToggles) api(projects.core.utils) // endregion @@ -29,6 +30,16 @@ dependencies { implementation(projects.data.common) // endregion + // region Project - Libs + implementation(projects.libs.crypto) + implementation(projects.libs.blockchainSdk) + // endregion + + // region Tangem dependencies + implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) + // endregion + // region DI implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index df872e31dc..61056db1c3 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,11 +1,14 @@ package com.tangem.data.account.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.account.converter.AccountConverterFactoryContainer +import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles import com.tangem.data.account.repository.DefaultAccountsCRUDRepository import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -18,6 +21,12 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) internal object AccountDataModule { + @Provides + @Singleton + fun provideAccountFeatureToggle(featureTogglesManager: FeatureTogglesManager): AccountsFeatureToggles { + return DefaultAccountsFeatureToggles(featureTogglesManager = featureTogglesManager) + } + @Provides @Singleton fun provideAccountsCRUDRepository( diff --git a/data/account/src/main/kotlin/com/tangem/data/account/featuretoggle/DefaultAccountsFeatureToggles.kt b/data/account/src/main/kotlin/com/tangem/data/account/featuretoggle/DefaultAccountsFeatureToggles.kt new file mode 100644 index 0000000000..0a6accf03f --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/featuretoggle/DefaultAccountsFeatureToggles.kt @@ -0,0 +1,12 @@ +package com.tangem.data.account.featuretoggle + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles + +internal class DefaultAccountsFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : AccountsFeatureToggles { + + override val isFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "ACCOUNTS_FEATURE_ENABLED") +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/featuretoggle/AccountsFeatureToggles.kt b/domain/account/src/main/java/com/tangem/domain/account/featuretoggle/AccountsFeatureToggles.kt new file mode 100644 index 0000000000..289b2a02e6 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/featuretoggle/AccountsFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.account.featuretoggle + +/** + * Accounts feature toggle + * +[REDACTED_AUTHOR] + */ +interface AccountsFeatureToggles { + + val isFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 33e667f9d0..a9c2010b63 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1212" +tangemBlockchainSdk = "develop-1213" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-560" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 8cff4c1bbae3bad1cef82ca8bbec2132de708ca6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 2 Sep 2025 15:27:17 +0300 Subject: [PATCH 037/132] Updated on 2026-08-14 --- .../DefaultUserWalletsListRepository.kt | 19 ++++++--- .../wallet/child/wallet/model/WalletModel.kt | 14 +++++++ .../model/WalletsUpdateActionResolver.kt | 39 +++++++++++++++++++ .../intents/WalletContentClickIntents.kt | 8 ++++ .../intents/WalletWarningsClickIntents.kt | 22 +++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 93810c48da..87edec2202 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -62,7 +62,7 @@ internal class DefaultUserWalletsListRepository( // If we don't save persistent information, we don't need to load user wallets // and we should clear any existing data clearPersistentData() - userWallets.value = emptyList() + updateWallets { emptyList() } return } @@ -118,7 +118,7 @@ internal class DefaultUserWalletsListRepository( } // update the userWallets state and add if it doesn't exist - userWallets.update { currentWallets -> + updateWallets { currentWallets -> val wallets = currentWallets ?: emptyList() if (wallets.any { it.walletId == userWallet.walletId }) { wallets.map { if (it.walletId == userWallet.walletId) userWallet else it } @@ -248,7 +248,7 @@ internal class DefaultUserWalletsListRepository( removePasswordAttempts(userWallet) sensitiveInformationRepository.getAll(listOf(encryptionKey)) - .doOnSuccess { sensitiveInfo -> userWallets.update { it?.updateWith(sensitiveInfo) } } + .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) } @@ -306,7 +306,7 @@ internal class DefaultUserWalletsListRepository( sensitiveInformationRepository.getAll(allKeys) .doOnSuccess { sensitiveInfo -> - userWallets.update { it?.updateWith(sensitiveInfo) } + updateWallets { it?.updateWith(sensitiveInfo) } } .doOnFailure { raise(UnlockWalletError.UnableToUnlock) } } @@ -318,7 +318,7 @@ internal class DefaultUserWalletsListRepository( raise(LockWalletsError.NothingToLock) } - userWallets.update { + updateWallets { it?.map { if (it.walletId !in unsecuredWalletIds) { it.lock() @@ -389,6 +389,15 @@ internal class DefaultUserWalletsListRepository( return tangemSdkManagerProvider.invoke().canUseBiometry && useBiometricAuthentication } + private fun updateWallets(block: (List?) -> List?) { + userWallets.update(block) + + selectedUserWallet.update { currentSelected -> + if (currentSelected == null) return@update null + userWallets.value?.find { it.walletId == currentSelected.walletId } + } + } + /** * Find the nearest available wallet that can be selected * diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index e7a00027c6..d90595d32f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -363,6 +363,9 @@ internal class WalletModel @Inject constructor( is WalletsUpdateActionResolver.Action.RenameWallets -> { stateHolder.update(transformer = RenameWalletsTransformer(renamedWallets = action.renamedWallets)) } + is WalletsUpdateActionResolver.Action.ReloadWarningsForWallets -> { + reloadWarnings(action) + } WalletsUpdateActionResolver.Action.EmptyWallets -> { Timber.w("Wallets list is empty!") } @@ -372,6 +375,17 @@ internal class WalletModel @Inject constructor( } } + private fun reloadWarnings(action: WalletsUpdateActionResolver.Action.ReloadWarningsForWallets) { + action.wallets.forEach { + walletScreenContentLoader.load( + userWallet = it, + clickIntents = clickIntents, + coroutineScope = modelScope, + isRefresh = true, + ) + } + } + private suspend fun initializeWallets(action: WalletsUpdateActionResolver.Action.InitializeWallets) { stateHolder.update( transformer = InitializeWalletsTransformer( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt index 3e9a4d114b..5c27b29b42 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt @@ -9,6 +9,7 @@ import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import timber.log.Timber @@ -75,10 +76,20 @@ internal class WalletsUpdateActionResolver @Inject constructor( isAnyWalletNameChanged(state, wallets) -> { getRenameWalletsAction(state, wallets) } + isAnyHotWalletBackedUp(state, wallets) -> { + getHotWalletsBackedUpAction(state, wallets) + } else -> getUpdateSelectedWalletAction(state, wallets, selectedWallet) } } + private fun isAnyHotWalletBackedUp(state: WalletScreenState, wallets: List): Boolean { + val incompleteActivationWalletIds = state.incompleteActivationWalletIds() + return incompleteActivationWalletIds.mapNotNull { walletId -> + wallets.firstOrNull { it.walletId == walletId && it is UserWallet.Hot && it.backedUp } + }.isNotEmpty() + } + private fun isWalletsCountChanged(state: WalletScreenState, wallets: List): Boolean { val prevWalletsSize = state.wallets.size val walletsSize = wallets.size @@ -145,6 +156,20 @@ internal class WalletsUpdateActionResolver @Inject constructor( return prevWalletsIds == newWalletsIds && isAnyNameChanged } + private fun getHotWalletsBackedUpAction( + state: WalletScreenState, + wallets: List, + ): Action.ReloadWarningsForWallets { + val incompleteActivationWalletIds = state.incompleteActivationWalletIds() + + val walletsToUpdate = wallets.filter { + it is UserWallet.Hot && it.backedUp && + incompleteActivationWalletIds.contains(it.walletId) + } + + return Action.ReloadWarningsForWallets(walletsToUpdate) + } + private fun getRenameWalletsAction(state: WalletScreenState, wallets: List): Action.RenameWallets { val prevWallets = state.wallets.map { it.walletCardState.id to it.walletCardState.title } val newWallets = wallets.map { it.walletId to it.name } @@ -199,6 +224,16 @@ internal class WalletsUpdateActionResolver @Inject constructor( ?: error("Previous selected wallet is not found") } + private fun WalletScreenState.incompleteActivationWalletIds(): List { + return wallets.mapNotNull { + if (it.warnings.any { it is WalletNotification.FinishWalletActivation }) { + it.walletCardState.id + } else { + null + } + } + } + private fun List.indexOfWallet(id: UserWalletId): Int { val selectedIndex = indexOfFirst { it.walletId == id } @@ -323,6 +358,10 @@ internal class WalletsUpdateActionResolver @Inject constructor( } } + data class ReloadWarningsForWallets( + val wallets: List, + ) : Action() + data object EmptyWallets : Action() data object Unknown : Action() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index cc5a440140..da6c773a77 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -24,6 +24,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBot import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletCurrencyActionsConverter import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.take @@ -76,9 +77,16 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val reduxStateHolder: ReduxStateHolder, private val walletEventSender: WalletEventSender, private val analyticsEventHandler: AnalyticsEventHandler, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : BaseWalletClickIntents(), WalletContentClickIntents { override fun onDetailsClick() { + if (hotWalletFeatureToggles.isHotWalletEnabled) { + router.openDetailsScreen(stateHolder.getSelectedWalletId()) + return + } + + // Will be removed after Hot Wallet release modelScope.launch(dispatchers.main) { val userWalletId = stateHolder.getSelectedWalletId() val userWallet = getUserWalletUseCase(userWalletId).getOrElse { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 28d03a317a..203128c7ce 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.domain.card.SetCardWasScannedUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency @@ -44,6 +45,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBottomShe import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -117,6 +119,8 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, private val stakingIdFactory: StakingIdFactory, private val appRouter: AppRouter, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, ) : BaseWalletClickIntents(), WalletWarningsClickIntents { override fun onAddBackupCardClick() { @@ -168,6 +172,22 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( override fun onOpenUnlockWalletsBottomSheetClick() { analyticsEventHandler.send(MainScreen.WalletUnlockTapped) + if (hotWalletFeatureToggles.isHotWalletEnabled) { + modelScope.launch { + userWalletsListRepository.unlockAllWallets() + .onLeft { + val selectedUserWallet = getSelectedUserWallet() ?: return@onLeft + val method = when (selectedUserWallet) { + is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan + is UserWallet.Hot -> UserWalletsListRepository.UnlockMethod.AccessCode + } + userWalletsListRepository.unlock(stateHolder.getSelectedWalletId(), method) + } + } + return + } + + // Will be removed after hot wallet release stateHolder.showBottomSheet( WalletBottomSheetConfig.UnlockWallets( onUnlockClick = this::onUnlockWalletClick, @@ -176,6 +196,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( ) } + @Deprecated("Will be removed with hot wallet release") override fun onUnlockWalletClick() { analyticsEventHandler.send(MainScreen.UnlockAllWithBiometrics) @@ -203,6 +224,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( walletEventSender.send(event) } + @Deprecated("Will be removed with hot wallet release") override fun onScanToUnlockWalletClick() { analyticsEventHandler.send(MainScreen.UnlockWithCardScan) openScanCardDialog() From ab6072e12d92d2ccecd04eb0e598425647ddff01 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 2 Sep 2025 17:49:20 +0400 Subject: [PATCH 038/132] Updated on 2026-08-14 --- .../tap/data/DefaultTangemPayStorage.kt | 39 +++++++++++++++ .../tangem/tap/di/data/VisaStorageModule.kt | 6 +++ .../tasks/visa/VisaCardActivationTask.kt | 6 +-- .../tap/domain/visa/VisaCardScanHandler.kt | 12 ++--- .../tangem/datasource/api/pay/TangemPayApi.kt | 3 ++ .../pay/models/response/CustomerMeResponse.kt | 48 +++++++++++++++++++ .../datasource/local/visa/TangemPayStorage.kt | 10 ++++ .../DefaultTangemPayAuthDataSource.kt | 45 +++++++++++++++++ .../tangem/data/pay/di/TangemPayDataModule.kt | 2 +- .../{ => repository}/DefaultKycRepository.kt | 48 ++++++++----------- .../visa/DefaultVisaActivationRepository.kt | 6 +-- ....kt => DefaultVisaAuthRemoteDataSource.kt} | 7 ++- .../com/tangem/data/visa/di/VisaDataModule.kt | 12 +++-- .../pay/datasource/TangemPayAuthDataSource.kt | 8 ++++ .../domain/pay/repository/KycRepository.kt | 8 ++++ .../VisaAuthRemoteDataSource.kt} | 4 +- .../model/OnboardingVisaAccessCodeModel.kt | 6 +-- .../model/OnboardingVisaInProgressModel.kt | 6 +-- 18 files changed, 219 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt rename data/visa/src/main/kotlin/com/tangem/data/pay/{ => repository}/DefaultKycRepository.kt (60%) rename data/visa/src/main/kotlin/com/tangem/data/visa/{DefaultVisaAuthRepository.kt => DefaultVisaAuthRemoteDataSource.kt} (97%) create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt rename domain/visa/src/main/kotlin/com/tangem/domain/visa/{repository/VisaAuthRepository.kt => datasource/VisaAuthRemoteDataSource.kt} (93%) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt new file mode 100644 index 0000000000..1b9e1d54d7 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -0,0 +1,39 @@ +package com.tangem.tap.data + +import android.content.Context +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.sdk.storage.AndroidSecureStorageV2 +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +private const val DEFAULT_KEY = "tangem_pay_default_key" + +@Singleton +internal class DefaultTangemPayStorage @Inject constructor( + @ApplicationContext applicationContext: Context, + private val dispatcherProvider: CoroutineDispatcherProvider, +) : TangemPayStorage { + + private val secureStorage by lazy { + AndroidSecureStorageV2( + appContext = applicationContext, + useStrongBox = false, + name = "tangem_pay_storage", + ) + } + + override suspend fun store(authHeader: String) = withContext(dispatcherProvider.io) { + secureStorage.store(authHeader.encodeToByteArray(throwOnInvalidSequence = true), DEFAULT_KEY) + } + + override suspend fun get(): String? = withContext(dispatcherProvider.io) { + secureStorage.get(DEFAULT_KEY)?.decodeToString(throwOnInvalidSequence = true) + } + + override suspend fun clear() = withContext(dispatcherProvider.io) { + secureStorage.delete(DEFAULT_KEY) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt index c78909a676..1927963c23 100644 --- a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt @@ -1,7 +1,9 @@ package com.tangem.tap.di.data +import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.datasource.local.visa.VisaOTPStorage +import com.tangem.tap.data.DefaultTangemPayStorage import com.tangem.tap.data.DefaultVisaAuthTokenStorage import com.tangem.tap.data.DefaultVisaOTPStorage import dagger.Binds @@ -21,4 +23,8 @@ internal interface VisaStorageModule { @Binds @Singleton fun bindVisaOTPStorage(impl: DefaultVisaOTPStorage): VisaOTPStorage + + @Binds + @Singleton + fun bindTangemPayStorage(impl: DefaultTangemPayStorage): TangemPayStorage } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt index 74ed0d0827..9dd041b2ea 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt @@ -22,7 +22,7 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.operations.GenerateOTPCommand import com.tangem.operations.attestation.AttestCardKeyCommand import com.tangem.operations.pins.SetUserCodeCommand @@ -46,7 +46,7 @@ class VisaCardActivationTask @AssistedInject constructor( @Assisted private val coroutineScope: CoroutineScope, private val otpStorage: VisaOTPStorage, private val visaAuthTokenStorage: VisaAuthTokenStorage, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, ) : CardSessionRunnable { @@ -168,7 +168,7 @@ class VisaCardActivationTask @AssistedInject constructor( signedChallenge: VisaAuthSignedChallenge, cardWalletAddress: String, ): Either = either { - val tokens = visaAuthRepository.getAccessTokens(signedChallenge) + val tokens = visaAuthRemoteDataSource.getAccessTokens(signedChallenge) .getOrElse { raise(it.tangemError) } visaAuthTokenStorage.store(cardId, tokens) diff --git a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt index c439ee3d5b..d074006981 100644 --- a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt +++ b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt @@ -15,7 +15,7 @@ import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.operations.attestation.AttestCardKeyCommand import com.tangem.operations.attestation.AttestCardKeyResponse import com.tangem.operations.attestation.AttestWalletKeyResponse @@ -26,7 +26,7 @@ import javax.inject.Inject import kotlin.coroutines.resume internal class VisaCardScanHandler @Inject constructor( - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, private val visaAuthTokenStorage: VisaAuthTokenStorage, ) { @@ -83,7 +83,7 @@ internal class VisaCardScanHandler @Inject constructor( Timber.i("Requesting challenge for wallet authorization") - val challengeResponse = visaAuthRepository.getCardWalletAuthChallenge( + val challengeResponse = visaAuthRemoteDataSource.getCardWalletAuthChallenge( cardId = card.cardId, // This is the wallet public key, not the address and it's alright, as the API expects it in this format cardWalletAddress = wallet.publicKey.toHexString(), @@ -122,7 +122,7 @@ internal class VisaCardScanHandler @Inject constructor( cardWalletAddress: String, signedChallenge: VisaAuthSignedChallenge, ): CompletionResult { - val authorizationTokensResponse = visaAuthRepository.getAccessTokens(signedChallenge = signedChallenge) + val authorizationTokensResponse = visaAuthRemoteDataSource.getAccessTokens(signedChallenge = signedChallenge) .getOrElse { Timber.i("Failed to get Access token for Wallet public key authorization.") return if ( @@ -149,7 +149,7 @@ internal class VisaCardScanHandler @Inject constructor( Timber.i("Requesting authorization challenge to sign") - val challengeResponse = visaAuthRepository.getCardAuthChallenge( + val challengeResponse = visaAuthRemoteDataSource.getCardAuthChallenge( cardId = card.cardId, cardPublicKey = card.cardPublicKey.toHexString(), ).getOrElse { @@ -174,7 +174,7 @@ internal class VisaCardScanHandler @Inject constructor( } } - val authorizationTokensResponse = visaAuthRepository.getAccessTokens( + val authorizationTokensResponse = visaAuthRemoteDataSource.getAccessTokens( signedChallenge = challengeResponse.toSignedChallenge( signedChallenge = attestCardKeyResponse.cardSignature.toHexString(), salt = attestCardKeyResponse.salt.toHexString(), diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 8678e1959c..7549cc097f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -103,4 +103,7 @@ interface TangemPayApi { @GET("v1/customer/kyc") suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse + + @GET("v1/customer/me") + suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt new file mode 100644 index 0000000000..6814d094a9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -0,0 +1,48 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CustomerMeResponse( + @Json(name = "result") val result: Result?, + @Json(name = "error") val error: String?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "id") val id: String, + @Json(name = "state") val state: String, + @Json(name = "createdAt") val createdAt: String, + @Json(name = "product_instance") val productInstance: ProductInstance, + @Json(name = "payment_account") val paymentAccount: PaymentAccount, + @Json(name = "kyc") val kyc: Kyc, + ) + + @JsonClass(generateAdapter = true) + data class ProductInstance( + @Json(name = "id") val id: String, + @Json(name = "cid") val cid: String, + @Json(name = "card_id") val cardId: String, + @Json(name = "card_wallet_address") val cardWalletAddress: String, + @Json(name = "status") val status: String, + @Json(name = "updated_at") val updatedAt: String, + @Json(name = "payment_account_id") val paymentAccountId: String, + ) + + @JsonClass(generateAdapter = true) + data class PaymentAccount( + @Json(name = "id") val id: String, + @Json(name = "address") val address: String, + @Json(name = "customer_wallet_address") val customerWalletAddress: String, + ) + + @JsonClass(generateAdapter = true) + data class Kyc( + @Json(name = "id") val id: String, + @Json(name = "provider") val provider: String, + @Json(name = "status") val status: String, + @Json(name = "risk") val risk: String, + @Json(name = "review_answer") val reviewAnswer: String, + @Json(name = "created_at") val createdAt: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt new file mode 100644 index 0000000000..d1f8d27e19 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.local.visa + +interface TangemPayStorage { + + suspend fun store(authHeader: String) + + suspend fun get(): String? + + suspend fun clear() +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt new file mode 100644 index 0000000000..80ade6e92b --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -0,0 +1,45 @@ +package com.tangem.data.pay.datasource + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.common.CompletionResult +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.visa.model.VisaDataForApprove +import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.sdk.api.TangemSdkManager +import javax.inject.Inject + +internal class DefaultTangemPayAuthDataSource @Inject constructor( + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, + private val tangemSdkManager: TangemSdkManager, +) : TangemPayAuthDataSource { + + override suspend fun generateNewAuthHeader(address: String, cardId: String): Either = either { + val challenge = visaAuthRemoteDataSource + .getCustomerWalletAuthChallenge(address) + .mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") } + .bind() + + val signed = tangemSdkManager.visaCustomerWalletApprove( + VisaDataForApprove( + customerWalletCardId = cardId, + targetAddress = address, + dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge), + ), + ).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind() + + visaAuthRemoteDataSource.getTokenWithCustomerWallet( + sessionId = challenge.session.sessionId, + signature = signed.signature, + nonce = signed.dataToSign.hashToSign, + ) + .mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") } + .bind() + } +} + +private fun CompletionResult.toEither(map: (Throwable) -> Throwable) = when (this) { + is CompletionResult.Success -> Either.Right(data) + is CompletionResult.Failure -> Either.Left(map(error)) +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index d0de7d5c31..727e31f796 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -1,6 +1,6 @@ package com.tangem.data.pay.di -import com.tangem.data.pay.DefaultKycRepository +import com.tangem.data.pay.repository.DefaultKycRepository import com.tangem.domain.pay.repository.KycRepository import dagger.Binds import dagger.Module diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultKycRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt similarity index 60% rename from data/visa/src/main/kotlin/com/tangem/data/pay/DefaultKycRepository.kt rename to data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt index cc1a69895e..5ecfd916c5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultKycRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt @@ -1,58 +1,48 @@ -package com.tangem.data.pay +package com.tangem.data.pay.repository import arrow.core.Either import com.squareup.moshi.Moshi -import com.tangem.common.map import com.tangem.core.error.UniversalError import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.pay.KycStartInfo +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.visa.error.VisaApiError -import com.tangem.domain.visa.model.VisaDataForApprove -import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet -import com.tangem.domain.visa.repository.VisaAuthRepository -import com.tangem.sdk.api.TangemSdkManager import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject class DefaultKycRepository @AssistedInject constructor( @NetworkMoshi moshi: Moshi, private val tangemPayApi: TangemPayApi, - private val visaAuthRepository: VisaAuthRepository, - private val tangemSdkManager: TangemSdkManager, + private val authDataSource: TangemPayAuthDataSource, + private val tangemPayStorage: TangemPayStorage, ) : KycRepository { private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) override suspend fun getKycStartInfo(address: String, cardId: String): Either { - var authHeader = "" - visaAuthRepository.getCustomerWalletAuthChallenge(address).getOrNull()?.let { result -> - tangemSdkManager.visaCustomerWalletApprove( - VisaDataForApprove( - customerWalletCardId = cardId, - targetAddress = address, - dataToSign = VisaDataToSignByCustomerWallet(hashToSign = result.challenge), - ), - ).map { signResult -> - visaAuthRepository.getTokenWithCustomerWallet( - sessionId = result.session.sessionId, - signature = signResult.signature, - nonce = signResult.dataToSign.hashToSign, - ).getOrNull()?.let { authHeader = it } - } - } + val authHeader = authDataSource.generateNewAuthHeader(address, cardId) + .getOrNull() + .takeIf { !it.isNullOrEmpty() } + ?: return Either.Left(VisaApiError.UnknownWithoutCode) + tangemPayStorage.store(authHeader) + return getKycInfo(authHeader) + } + + override suspend fun getKycStartInfo(authHeader: String): Either { + return getKycInfo(authHeader) + } + + private suspend fun getKycInfo(authHeader: String): Either { return request { - authHeader.ifEmpty { error("Cannot get auth header for KYC") } tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result }.map { - KycStartInfo( - token = it.token, - locale = it.locale, - ) + KycStartInfo(token = it.token, locale = it.locale) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt index b38b9a82a8..939794afce 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt @@ -18,7 +18,7 @@ import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -32,7 +32,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( private val visaApi: TangemPayApi, private val dispatcherProvider: CoroutineDispatcherProvider, private val visaAuthTokenStorage: VisaAuthTokenStorage, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaLibLoader: VisaLibLoader, private val apiConfigsManager: ApiConfigsManager, ) : VisaActivationRepository { @@ -194,7 +194,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( } val authTokens = visaAuthTokenStorage.get(visaCardId.cardId) ?: error("Auth tokens are not stored") - val newTokens = visaAuthRepository.refreshAccessTokens(authTokens.refreshToken).getOrElse { + val newTokens = visaAuthRemoteDataSource.refreshAccessTokens(authTokens.refreshToken).getOrElse { return Either.Left(VisaApiError.RefreshTokenExpired) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt similarity index 97% rename from data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt rename to data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt index 8b3024f1fe..e396978597 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt @@ -13,17 +13,16 @@ import com.tangem.domain.visa.model.VisaAuthChallenge import com.tangem.domain.visa.model.VisaAuthSession import com.tangem.domain.visa.model.VisaAuthSignedChallenge import com.tangem.domain.visa.model.VisaAuthTokens -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import javax.inject.Inject -@Suppress("UnusedPrivateMember") -internal class DefaultVisaAuthRepository @Inject constructor( +internal class DefaultVisaAuthRemoteDataSource @Inject constructor( @NetworkMoshi private val moshi: Moshi, private val visaAuthApi: TangemPayApi, private val dispatchers: CoroutineDispatcherProvider, -) : VisaAuthRepository { +) : VisaAuthRemoteDataSource { private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt index a173f8c5ea..a5efc3d025 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/di/VisaDataModule.kt @@ -1,10 +1,12 @@ package com.tangem.data.visa.di +import com.tangem.data.pay.datasource.DefaultTangemPayAuthDataSource import com.tangem.data.visa.DefaultVisaActivationRepository -import com.tangem.data.visa.DefaultVisaAuthRepository +import com.tangem.data.visa.DefaultVisaAuthRemoteDataSource import com.tangem.data.visa.MockVisaRepository +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.domain.visa.repository.VisaRepository import dagger.Binds import dagger.Module @@ -18,7 +20,7 @@ internal interface VisaDataModule { @Binds @Singleton - fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository + fun bindVisaAuthRemoteDataSource(repository: DefaultVisaAuthRemoteDataSource): VisaAuthRemoteDataSource @Binds @Singleton @@ -39,4 +41,8 @@ internal interface VisaDataModule { // Mocked @Binds fun bindVisaRepository(repository: MockVisaRepository): VisaRepository + + @Binds + @Singleton + fun bindTangemPayAuthDataSource(repository: DefaultTangemPayAuthDataSource): TangemPayAuthDataSource } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt new file mode 100644 index 0000000000..323d779515 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.pay.datasource + +import arrow.core.Either + +interface TangemPayAuthDataSource { + + suspend fun generateNewAuthHeader(address: String, cardId: String): Either +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt index 7d46ff2d52..5d4ef99fd3 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt @@ -6,8 +6,16 @@ import com.tangem.domain.pay.KycStartInfo interface KycRepository { + /** + * Returns fresh KYC data to start the survey. Used only for first time launch + */ suspend fun getKycStartInfo(address: String, cardId: String): Either + /** + * Returns KYC data to continue the survey. Used when KYC wasn't finished by the user + */ + suspend fun getKycStartInfo(authHeader: String): Either + interface Factory { fun create(): KycRepository } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt similarity index 93% rename from domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt rename to domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt index 098ca44c00..ebec77de6b 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaAuthRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.visa.repository +package com.tangem.domain.visa.datasource import arrow.core.Either import com.tangem.domain.visa.error.VisaApiError @@ -6,7 +6,7 @@ import com.tangem.domain.visa.model.VisaAuthChallenge import com.tangem.domain.visa.model.VisaAuthSignedChallenge import com.tangem.domain.visa.model.VisaAuthTokens -interface VisaAuthRepository { +interface VisaAuthRemoteDataSource { suspend fun getCardAuthChallenge( cardId: String, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt index 15c3b08a41..b80bb62fda 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt @@ -18,7 +18,7 @@ import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent @@ -44,7 +44,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, @Suppress("UnusedPrivateMember") private val tangemSdkManager: TangemSdkManager, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val uiMessageSender: UiMessageSender, private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { @@ -155,7 +155,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( loading(true) modelScope.launch { - val challengeToSign = visaAuthRepository.getCardAuthChallenge( + val challengeToSign = visaAuthRemoteDataSource.getCardAuthChallenge( cardId = activationInput.cardId, cardPublicKey = activationInput.cardPublicKey, ).getOrElse { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt index e517a628cb..1786357959 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt @@ -18,7 +18,7 @@ import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.repository.VisaAuthRepository +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.SaveWalletUseCase @@ -42,7 +42,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( paramsContainer: ParamsContainer, visaActivationRepositoryFactory: VisaActivationRepository.Factory, override val dispatchers: CoroutineDispatcherProvider, - private val visaAuthRepository: VisaAuthRepository, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, private val visaAuthTokenStorage: VisaAuthTokenStorage, private val otpStorage: VisaOTPStorage, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, @@ -166,7 +166,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val authTokens = visaAuthTokenStorage.get(params.scanResponse.card.cardId) ?: error("Auth tokens are not found. This should not happen.") - val newTokens = visaAuthRepository.exchangeAccessToken(authTokens) + val newTokens = visaAuthRemoteDataSource.exchangeAccessToken(authTokens) .getOrElse { uiMessageSender.showErrorDialog(it) return From d34a83ced5d9137aa76cdf5546be78a32d7108d1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 2 Sep 2025 15:50:42 +0200 Subject: [PATCH 039/132] Updated on 2026-08-14 --- domain/onramp/build.gradle.kts | 11 + .../tangem/domain/onramp/model/OnrampOffer.kt | 20 ++ .../onramp/model/OnrampPaymentMethod.kt | 36 ++- .../onramp/model/OnrampPaymentMethodGroup.kt | 19 ++ .../onramp/GetOnrampAllOffersUseCase.kt | 76 +++++ .../domain/onramp/GetOnrampOffersUseCase.kt | 197 ++++++++++++ .../onramp/GetOnrampAllOffersUseCaseTest.kt | 177 +++++++++++ .../onramp/GetOnrampOffersUseCaseTest.kt | 280 ++++++++++++++++++ 8 files changed, 814 insertions(+), 2 deletions(-) create mode 100644 domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt create mode 100644 domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt create mode 100644 domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt create mode 100644 domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt create mode 100644 domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt create mode 100644 domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt diff --git a/domain/onramp/build.gradle.kts b/domain/onramp/build.gradle.kts index da56879693..7823e8dcea 100644 --- a/domain/onramp/build.gradle.kts +++ b/domain/onramp/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Core modules */ implementation(projects.core.analytics.models) @@ -15,4 +19,11 @@ dependencies { api(projects.domain.core) api(projects.domain.settings) implementation(deps.kotlin.serialization) + + /** Tests */ + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt new file mode 100644 index 0000000000..e116e55735 --- /dev/null +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.onramp.model + +data class OnrampOffersBlock( + val category: OnrampOfferCategory, + val offers: List, + val isVisible: Boolean = offers.isNotEmpty(), +) + +data class OnrampOffer( + val quote: OnrampQuote, + val advantages: OnrampOfferAdvantages = OnrampOfferAdvantages.Default, +) + +enum class OnrampOfferAdvantages { + Default, BestRate, Fastest, +} + +enum class OnrampOfferCategory { + Recent, Recommended, +} \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt index a786bb843b..17c2e41ae0 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt @@ -13,27 +13,59 @@ data class OnrampPaymentMethod( enum class PaymentMethodType(val id: String?) { GOOGLE_PAY(id = "google-pay"), CARD(id = "card"), + REVOLUT_PAY(id = "invoice-revolut-pay"), + SEPA(id = "sepa"), OTHER(id = null), ; + @Suppress("MagicNumber") fun getPriority(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { when (this) { GOOGLE_PAY -> 0 CARD -> 1 - OTHER -> 2 + SEPA -> 2 + REVOLUT_PAY -> 3 + OTHER -> 4 } } else { when (this) { CARD -> 0 GOOGLE_PAY -> 1 - OTHER -> 2 + SEPA -> 2 + REVOLUT_PAY -> 3 + OTHER -> 4 } } + /** + * BE AWARE. HARDCODED. Returns the speed of transaction for payment method type. + */ + fun getProcessingSpeed(): PaymentSpeed = when (this) { + REVOLUT_PAY, + GOOGLE_PAY, + -> PaymentSpeed.Instant + CARD -> PaymentSpeed.FewMin + SEPA -> PaymentSpeed.FewDays + OTHER -> PaymentSpeed.PlentyDays + } + + /** + * @param speed - the lower the value, the faster the speed. + */ + @Suppress("MagicNumber") + enum class PaymentSpeed(val speed: Int) { + Instant(0), FewMin(1), FewDays(2), PlentyDays(3), Unknown(4) + } + + fun isInstant(): Boolean = getProcessingSpeed() == PaymentSpeed.Instant + companion object { + fun getType(id: String): PaymentMethodType = when (id) { GOOGLE_PAY.id -> GOOGLE_PAY CARD.id -> CARD + REVOLUT_PAY.id -> REVOLUT_PAY + SEPA.id -> SEPA else -> OTHER } } diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt new file mode 100644 index 0000000000..9a957d44ff --- /dev/null +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.onramp.model + +import java.math.BigDecimal + +data class OnrampPaymentMethodGroup( + val paymentMethod: OnrampPaymentMethod, + val offers: List, + val bestRateOffer: OnrampOffer?, + val providerCount: Int, + val isBestPaymentMethod: Boolean, +) { + + val bestRateAmount: BigDecimal? = bestRateOffer?.let { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt new file mode 100644 index 0000000000..6cc1247867 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt @@ -0,0 +1,76 @@ +package com.tangem.domain.onramp + +import arrow.core.left +import arrow.core.right +import com.tangem.domain.core.utils.EitherFlow +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampOffer +import com.tangem.domain.onramp.model.OnrampOfferAdvantages +import com.tangem.domain.onramp.model.OnrampPaymentMethodGroup +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.settings.repositories.SettingsRepository +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +class GetOnrampAllOffersUseCase( + private val onrampRepository: OnrampRepository, + private val errorResolver: OnrampErrorResolver, + private val settingsRepository: SettingsRepository, +) { + + operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): EitherFlow> { + return onrampRepository.getQuotes() + .map { quotes -> processAllOffers(quotes).right() } + .catch { throwable -> errorResolver.resolve(throwable).left() } + } + + private suspend fun processAllOffers(quotes: List): List { + val validQuotes = quotes.filterIsInstance() + if (validQuotes.isEmpty()) return emptyList() + val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() + + val overallBestRateQuote = validQuotes.maxByOrNull { it.toAmount.value } + + val offersByPaymentMethod = validQuotes.groupBy { it.paymentMethod } + + return offersByPaymentMethod.map { (paymentMethod, methodQuotes) -> + val methodOffers = methodQuotes.map { quote -> + val advantages = if (quote == overallBestRateQuote) { + OnrampOfferAdvantages.BestRate + } else { + OnrampOfferAdvantages.Default + } + OnrampOffer(quote = quote, advantages = advantages) + } + + val groupBestRateOfferData = methodQuotes.maxByOrNull { it.toAmount.value } + val groupBestRateOffer = methodOffers.find { + when (val quote = it.quote) { + is OnrampQuote.Data -> quote == groupBestRateOfferData + else -> false + } + } + + OnrampPaymentMethodGroup( + paymentMethod = paymentMethod, + offers = methodOffers.sortedByDescending { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + }, + providerCount = methodOffers.map { it.quote.provider.id }.distinct().size, + bestRateOffer = groupBestRateOffer, + isBestPaymentMethod = overallBestRateQuote?.paymentMethod == paymentMethod, + ) + }.sortedBy { it.paymentMethod.type.getPriority(isGooglePayAvailable) } + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt new file mode 100644 index 0000000000..29041336a9 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt @@ -0,0 +1,197 @@ +package com.tangem.domain.onramp + +import arrow.core.left +import arrow.core.right +import com.tangem.domain.core.utils.EitherFlow +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.* +import com.tangem.domain.onramp.model.cache.OnrampTransaction +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +class GetOnrampOffersUseCase( + private val onrampRepository: OnrampRepository, + private val onrampTransactionRepository: OnrampTransactionRepository, + private val errorResolver: OnrampErrorResolver, +) { + + operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): EitherFlow> { + return combine( + onrampRepository.getQuotes(), + onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId), + ) { quotes, transactions -> + processOffers(quotes, transactions) + } + .map { offers -> offers.right() } + .catch { throwable -> errorResolver.resolve(throwable).left() } + } + + private fun processOffers( + quotes: List, + transactions: List, + ): List { + val validQuotes = quotes.filterIsInstance() + if (validQuotes.isEmpty()) return emptyList() + + val offers = validQuotes.map { quote -> + OnrampOffer(quote = quote) + } + + val recentOffer = findRecentOffer(offers, transactions) + val bestRateOffer = findBestRateOffer(offers) + val fastestOffer = findFastestOffer(offers) + + return buildOffersBlocks( + recentOffer = recentOffer, + bestRateOffer = bestRateOffer, + fastestOffer = fastestOffer, + allOffers = offers, + ) + } + + private fun findRecentOffer(offers: List, transactions: List): OnrampOffer? { + val lastTransaction = transactions.maxByOrNull { it.timestamp } ?: return null + + return offers.find { offer -> + offer.quote.provider.id == lastTransaction.providerType && + offer.quote.paymentMethod.id == lastTransaction.paymentMethod + } + } + + private fun findBestRateOffer(offers: List): OnrampOffer? { + return offers.maxByOrNull { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } + } + + private fun findFastestOffer(offers: List): OnrampOffer? { + val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() } + return if (instantOffers.isNotEmpty()) { + instantOffers.maxByOrNull { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } + } else { + val offersBySpeed = offers.groupBy { offer -> + offer.quote.paymentMethod.type.getProcessingSpeed().speed + } + val fastestSpeed = offersBySpeed.keys.minOrNull() ?: return null + val fastestOffers = offersBySpeed[fastestSpeed] ?: return null + fastestOffers.maxByOrNull { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } + } + } + + private fun buildOffersBlocks( + recentOffer: OnrampOffer?, + bestRateOffer: OnrampOffer?, + fastestOffer: OnrampOffer?, + allOffers: List, + ): List { + return buildList { + if (recentOffer != null) { + add( + OnrampOffersBlock( + category = OnrampOfferCategory.Recent, + offers = listOf( + recentOffer.copy( + advantages = determineAdvantages( + recentOffer, + bestRateOffer, + fastestOffer, + ), + ), + ), + ), + ) + } + + val recommendedOffers = buildRecommendedOffers( + recentOffer = recentOffer, + bestRateOffer = bestRateOffer, + fastestOffer = fastestOffer, + ) + + if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) { + add( + OnrampOffersBlock( + category = OnrampOfferCategory.Recommended, + offers = recommendedOffers, + ), + ) + } + } + } + + private fun determineAdvantages( + recentOffer: OnrampOffer, + bestRateOffer: OnrampOffer?, + fastestOffer: OnrampOffer?, + ): OnrampOfferAdvantages { + if (isSameOffer(recentOffer, bestRateOffer) && isSameOffer(recentOffer, fastestOffer)) { + return OnrampOfferAdvantages.BestRate + } + if (isSameOffer(recentOffer, bestRateOffer)) { + return OnrampOfferAdvantages.BestRate + } + if (isSameOffer(recentOffer, fastestOffer)) { + return OnrampOfferAdvantages.Fastest + } + return OnrampOfferAdvantages.Default + } + + private fun buildRecommendedOffers( + recentOffer: OnrampOffer?, + bestRateOffer: OnrampOffer?, + fastestOffer: OnrampOffer?, + ): List { + return buildList { + if (isSameOffer(bestRateOffer, fastestOffer)) { + bestRateOffer?.let { offer -> + add(offer.copy(advantages = OnrampOfferAdvantages.BestRate)) + } + } else { + if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) { + add(bestRateOffer.copy(advantages = OnrampOfferAdvantages.BestRate)) + } + + if (fastestOffer != null && !isSameOffer(fastestOffer, recentOffer) && + !isSameOffer(fastestOffer, bestRateOffer) + ) { + add(fastestOffer.copy(advantages = OnrampOfferAdvantages.Fastest)) + } + } + } + } + + private fun hasOnlyOneMethodAndProvider(offers: List): Boolean { + val uniquePaymentMethods = offers.map { it.quote.paymentMethod.id }.distinct() + val uniqueProviders = offers.map { it.quote.provider.id }.distinct() + return uniquePaymentMethods.size == 1 && uniqueProviders.size == 1 + } + + private fun isSameOffer(offer1: OnrampOffer?, offer2: OnrampOffer?): Boolean { + if (offer1 == null || offer2 == null) return false + return offer1.quote.provider.id == offer2.quote.provider.id && + offer1.quote.paymentMethod.id == offer2.quote.paymentMethod.id + } +} \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt new file mode 100644 index 0000000000..89c4b8e6f9 --- /dev/null +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt @@ -0,0 +1,177 @@ +package com.tangem.domain.onramp + +import com.google.common.truth.Truth +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampOfferAdvantages +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.OnrampProvider +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.settings.repositories.SettingsRepository +import io.mockk.* +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetOnrampAllOffersUseCaseTest { + + private val onrampRepository: OnrampRepository = mockk(relaxUnitFun = true) + private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true) + private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true) + private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true) + private val userWalletId: UserWalletId = mockk(relaxUnitFun = true) + + private lateinit var useCase: GetOnrampAllOffersUseCase + + @BeforeEach + fun setup() { + clearMocks(onrampRepository, errorResolver, settingsRepository, cryptoCurrencyId) + useCase = GetOnrampAllOffersUseCase( + onrampRepository = onrampRepository, + errorResolver = errorResolver, + settingsRepository = settingsRepository, + ) + } + + @Test + fun `invoke should return empty list when no valid quotes`() = runTest { + val emptyQuotes = listOf() + coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> Truth.assertThat(offers).isEmpty() }, + ) + } + coVerify { onrampRepository.getQuotes() } + } + + @Test + fun `invoke should return grouped offers with best rate marked`() = runTest { + val paymentMethod1 = createMockPaymentMethod("card", "Card") + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer") + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(paymentMethod1, provider1, BigDecimal("100.0")), + createMockQuote(paymentMethod1, provider2, BigDecimal("95.0")), + createMockQuote(paymentMethod2, provider1, BigDecimal("98.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(2) + + val cardGroup = offers.find { it.paymentMethod.id == "card" } + Truth.assertThat(cardGroup).isNotNull() + Truth.assertThat(cardGroup?.offers).hasSize(2) + Truth.assertThat(cardGroup?.providerCount).isEqualTo(2) + Truth.assertThat(cardGroup?.isBestPaymentMethod).isTrue() + + val bestRateOffer = cardGroup?.offers?.find { it.advantages == OnrampOfferAdvantages.BestRate } + Truth.assertThat(bestRateOffer).isNotNull() + + val bankGroup = offers.find { it.paymentMethod.id == "bank" } + Truth.assertThat(bankGroup).isNotNull() + Truth.assertThat(bankGroup?.offers).hasSize(1) + Truth.assertThat(bankGroup?.providerCount).isEqualTo(1) + Truth.assertThat(bankGroup?.isBestPaymentMethod).isFalse() + }, + ) + } + + coVerify { onrampRepository.getQuotes() } + coVerify { settingsRepository.isGooglePayAvailability() } + } + + @Test + fun `invoke should sort offers by toAmount descending`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card") + val provider = createMockProvider("provider1", "Provider 1") + + val quotes = listOf( + createMockQuote(paymentMethod, provider, BigDecimal("90.0")), + createMockQuote(paymentMethod, provider, BigDecimal("100.0")), + createMockQuote(paymentMethod, provider, BigDecimal("95.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + val group = offers.first() + Truth.assertThat(group.offers).hasSize(3) + + val amounts = group.offers.map { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } + Truth.assertThat(amounts).containsExactly( + BigDecimal("100.0"), + BigDecimal("95.0"), + BigDecimal("90.0"), + ).inOrder() + }, + ) + } + } + + private fun createMockPaymentMethod(id: String, name: String): OnrampPaymentMethod { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.name } returns name + every { this@mockk.type } returns mockk { + every { getPriority(any()) } returns 1 + } + } + } + + private fun createMockProvider(id: String, name: String): OnrampProvider { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.info.name } returns name + } + } + + private fun createMockQuote( + paymentMethod: OnrampPaymentMethod, + provider: OnrampProvider, + toAmount: BigDecimal, + ): OnrampQuote.Data { + return mockk { + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.provider } returns provider + every { this@mockk.toAmount } returns mockk { + every { value } returns toAmount + } + } + } +} \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt new file mode 100644 index 0000000000..62b55b230a --- /dev/null +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt @@ -0,0 +1,280 @@ +package com.tangem.domain.onramp + +import com.google.common.truth.Truth +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.* +import com.tangem.domain.onramp.model.cache.OnrampTransaction +import com.tangem.domain.onramp.repositories.OnrampErrorResolver +import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import io.mockk.* +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetOnrampOffersUseCaseTest { + + private val onrampRepository: OnrampRepository = mockk(relaxUnitFun = true) + private val onrampTransactionRepository: OnrampTransactionRepository = mockk(relaxUnitFun = true) + private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true) + private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true) + private val userWalletId: UserWalletId = mockk(relaxUnitFun = true) + + private lateinit var useCase: GetOnrampOffersUseCase + + @BeforeEach + fun setup() { + clearMocks(onrampRepository, onrampTransactionRepository, errorResolver, cryptoCurrencyId) + useCase = GetOnrampOffersUseCase( + onrampRepository = onrampRepository, + onrampTransactionRepository = onrampTransactionRepository, + errorResolver = errorResolver, + ) + } + + @Test + fun `invoke should return empty list when no valid quotes`() = runTest { + val emptyQuotes = listOf() + val emptyTransactions = listOf() + + coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + emptyTransactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> Truth.assertThat(offers).isEmpty() }, + ) + } + + coVerify { onrampRepository.getQuotes() } + coVerify { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } + } + + @Test + fun `invoke should return offers blocks with recent and recommended categories`() = runTest { + val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = true) + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(paymentMethod1, provider1, BigDecimal("90.0")), + createMockQuote(paymentMethod2, provider2, BigDecimal("100.0")), + ) + + val transactions = listOf( + createMockTransaction("provider1", "card", 1000L), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(2) + + val recentBlock = offers.find { it.category == OnrampOfferCategory.Recent } + Truth.assertThat(recentBlock).isNotNull() + Truth.assertThat(recentBlock?.offers).hasSize(1) + Truth.assertThat(recentBlock?.offers?.first()?.advantages).isEqualTo(OnrampOfferAdvantages.Fastest) + + val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended } + Truth.assertThat(recommendedBlock).isNotNull() + Truth.assertThat(recommendedBlock?.offers).hasSize(1) + Truth.assertThat(recommendedBlock?.offers?.first()?.advantages) + .isEqualTo(OnrampOfferAdvantages.BestRate) + }, + ) + } + } + + @Test + fun `invoke should find best rate offer correctly`() = runTest { + val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = false) + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(paymentMethod1, provider1, BigDecimal("90.0")), + createMockQuote(paymentMethod2, provider2, BigDecimal("100.0")), + createMockQuote(paymentMethod1, provider1, BigDecimal("95.0")), + ) + + val transactions = emptyList() + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended } + Truth.assertThat(recommendedBlock).isNotNull() + Truth.assertThat(recommendedBlock?.offers).hasSize(1) + + val bestRateOffer = recommendedBlock?.offers?.first() + Truth.assertThat(bestRateOffer?.advantages).isEqualTo(OnrampOfferAdvantages.BestRate) + + when (val quote = bestRateOffer?.quote) { + is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0")) + else -> Truth.assertThat(false).isTrue() + } + }, + ) + } + } + + @Test + fun `invoke should find fastest offer correctly`() = runTest { + val instantPaymentMethod = createMockPaymentMethod("card", "Card", isInstant = true) + val slowPaymentMethod = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockQuote(instantPaymentMethod, provider1, BigDecimal("90.0")), + createMockQuote(slowPaymentMethod, provider2, BigDecimal("100.0")), + ) + + val transactions = emptyList() + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended } + Truth.assertThat(recommendedBlock).isNotNull() + Truth.assertThat(recommendedBlock?.offers).hasSize(2) + + val bestRateOffer = recommendedBlock + ?.offers + ?.find { it.advantages == OnrampOfferAdvantages.BestRate } + val fastestOffer = recommendedBlock + ?.offers + ?.find { it.advantages == OnrampOfferAdvantages.Fastest } + + Truth.assertThat(bestRateOffer).isNotNull() + Truth.assertThat(fastestOffer).isNotNull() + + when (val quote = bestRateOffer?.quote) { + is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0")) + else -> Truth.assertThat(false).isTrue() + } + + when (val quote = fastestOffer?.quote) { + is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("90.0")) + else -> Truth.assertThat(false).isTrue() + } + }, + ) + } + } + + @Test + fun `invoke should not show recommended block when only one method and provider`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", isInstant = false) + val provider = createMockProvider("provider1", "Provider 1") + + val quotes = listOf( + createMockQuote(paymentMethod, provider, BigDecimal("100.0")), + ) + + val transactions = emptyList() + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + transactions, + ) + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).isEmpty() + }, + ) + } + } + + private fun createMockPaymentMethod(id: String, name: String, isInstant: Boolean): OnrampPaymentMethod { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.name } returns name + every { this@mockk.type } returns mockk { + every { isInstant() } returns isInstant + every { getProcessingSpeed() } returns mockk { + every { speed } returns if (isInstant) 1 else 3 + } + } + } + } + + private fun createMockProvider(id: String, name: String): OnrampProvider { + return mockk { + every { this@mockk.id } returns id + every { this@mockk.info.name } returns name + } + } + + private fun createMockQuote( + paymentMethod: OnrampPaymentMethod, + provider: OnrampProvider, + toAmount: BigDecimal, + ): OnrampQuote.Data { + return mockk { + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.provider } returns provider + every { this@mockk.toAmount } returns mockk { + every { value } returns toAmount + } + } + } + + private fun createMockTransaction(providerType: String, paymentMethod: String, timestamp: Long): OnrampTransaction { + return mockk { + every { this@mockk.providerType } returns providerType + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.timestamp } returns timestamp + } + } +} \ No newline at end of file From 498e62329a202d34b961633dcf7d1869bf0c0db8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 2 Sep 2025 15:51:42 +0200 Subject: [PATCH 040/132] Updated on 2026-08-14 --- .../mainv2/DefaultOnrampV2MainComponent.kt | 79 ++++ .../DefaultOnrampV2MainFeatureToggle.kt} | 6 +- .../OnrampV2MainComponent.kt} | 6 +- .../mainv2/OnrampV2MainFeatureToggle.kt | 5 + .../di/OnrampMainV2ComponentModelModule.kt | 20 + .../mainv2/di/OnrampNewV2ComponentModule.kt | 33 ++ .../mainv2/entity/OnrampOfferBlockUM.kt | 41 ++ .../mainv2/entity/OnrampV2AmountBlockUM.kt | 38 ++ .../entity/OnrampV2MainBottomSheetConfig.kt | 13 + .../mainv2/entity/OnrampV2MainComponentUM.kt | 43 +++ .../mainv2/entity/OnrampV2ProvidersUM.kt | 15 + .../OnrampV2AmountFieldChangeConverter.kt | 84 ++++ .../OnrampAmountButtonUMStateFactory.kt | 35 ++ .../factory/OnrampV2AmountStateFactory.kt | 222 +++++++++++ .../entity/factory/OnrampV2StateFactory.kt | 205 ++++++++++ .../model/OnrampV2MainComponentModel.kt | 359 ++++++++++++++++++ .../onramp/mainv2/ui/OnrampFooterContent.kt | 130 +++++++ .../ui/OnrampNewMainComponentContent.kt | 141 +++++++ .../onramp/mainv2/ui/OnrampOffersContent.kt | 32 ++ .../onramp/mainv2/ui/OnrampV2AmountContent.kt | 199 ++++++++++ .../newmain/OnrampNewMainFeatureToggle.kt | 5 - 21 files changed, 1700 insertions(+), 11 deletions(-) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt rename features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/{newmain/DefaultOnrampNewMainFeatureToggle.kt => mainv2/DefaultOnrampV2MainFeatureToggle.kt} (69%) rename features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/{newmain/OnrampNewMainComponent.kt => mainv2/OnrampV2MainComponent.kt} (76%) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt new file mode 100644 index 0000000000..a651416d7f --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt @@ -0,0 +1,79 @@ +package com.tangem.features.onramp.mainv2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainBottomSheetConfig +import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel +import com.tangem.features.onramp.mainv2.ui.OnrampNewMainScreen +import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultOnrampV2MainComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: OnrampV2MainComponent.Params, + private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory, + private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory, +) : OnrampV2MainComponent, AppComponentContext by appComponentContext { + + private val model: OnrampV2MainComponentModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsState() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + OnrampNewMainScreen(modifier = modifier, state = state) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: OnrampV2MainBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + is OnrampV2MainBottomSheetConfig.ConfirmResidency -> confirmResidencyComponentFactory.create( + context = childByContext(componentContext), + params = ConfirmResidencyComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + country = config.country, + onDismiss = { model.bottomSheetNavigation.dismiss() }, + ), + ) + is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create( + context = childByContext(componentContext), + params = SelectCurrencyComponent.Params( + userWallet = model.userWallet, + cryptoCurrency = params.cryptoCurrency, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + } + + @AssistedFactory + interface Factory : OnrampV2MainComponent.Factory { + override fun create( + context: AppComponentContext, + params: OnrampV2MainComponent.Params, + ): DefaultOnrampV2MainComponent + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt similarity index 69% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt rename to features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt index e73a8570bd..256bead28d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt @@ -1,10 +1,10 @@ -package com.tangem.features.onramp.newmain +package com.tangem.features.onramp.mainv2 import com.tangem.core.configtoggle.feature.FeatureTogglesManager -class DefaultOnrampNewMainFeatureToggle( +class DefaultOnrampV2MainFeatureToggle( private val featureTogglesManager: FeatureTogglesManager, -) : OnrampNewMainFeatureToggle { +) : OnrampV2MainFeatureToggle { override val isOnrampNewMainEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_RECEIVE_ENABLED") } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt similarity index 76% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt rename to features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt index d30d134f81..9767cf4496 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.onramp.newmain +package com.tangem.features.onramp.mainv2 import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent @@ -7,7 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampSource -internal interface OnrampNewMainComponent : ComposableContentComponent { +internal interface OnrampV2MainComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, @@ -17,5 +17,5 @@ internal interface OnrampNewMainComponent : ComposableContentComponent { val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, ) - interface Factory : ComponentFactory + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt new file mode 100644 index 0000000000..54595ff8d7 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt @@ -0,0 +1,5 @@ +package com.tangem.features.onramp.mainv2 + +internal interface OnrampV2MainFeatureToggle { + val isOnrampNewMainEnabled: Boolean +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt new file mode 100644 index 0000000000..6d20f87cf7 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onramp.mainv2.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface OnrampMainV2ComponentModelModule { + + @Binds + @IntoMap + @ClassKey(OnrampV2MainComponentModel::class) + fun bindOnrampSelectCountryModel(model: OnrampV2MainComponentModel): Model +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt new file mode 100644 index 0000000000..08817d31ac --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt @@ -0,0 +1,33 @@ +package com.tangem.features.onramp.mainv2.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainComponent +import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainFeatureToggle +import com.tangem.features.onramp.mainv2.OnrampV2MainComponent +import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface OnrampNewMainComponentModule { + + @Binds + @Singleton + fun bindOnrampV2MainComponentFactory(factory: DefaultOnrampV2MainComponent.Factory): OnrampV2MainComponent.Factory +} + +@Module +@InstallIn(SingletonComponent::class) +internal object FeatureToggleModule { + + @Provides + @Singleton + fun provideOnrampV2MainFeatureToggle(featureTogglesManager: FeatureTogglesManager): OnrampV2MainFeatureToggle { + return DefaultOnrampV2MainFeatureToggle(featureTogglesManager = featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt new file mode 100644 index 0000000000..583cc962bb --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt @@ -0,0 +1,41 @@ +package com.tangem.features.onramp.mainv2.entity + +import androidx.compose.runtime.Immutable +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface OnrampOffersBlockUM { + + val isBlockVisible: Boolean + + data object Empty : OnrampOffersBlockUM { + override val isBlockVisible: Boolean + get() = false + } + + data class Loading( + override val isBlockVisible: Boolean, + ) : OnrampOffersBlockUM + + data class Content( + override val isBlockVisible: Boolean, + val offers: ImmutableList, + ) : OnrampOffersBlockUM +} + +internal data class OnrampOfferUM( + val category: OnrampOfferCategory, + val advantages: OnrampOfferAdvantages, + val paymentMethod: OnrampPaymentMethod, + val providerId: String, + val providerName: String, +) + +internal enum class OnrampOfferCategory { + RecentlyUsed, Recommended +} + +internal enum class OnrampOfferAdvantages { + Default, BestRate, Fastest +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt new file mode 100644 index 0000000000..b151b7818c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt @@ -0,0 +1,38 @@ +package com.tangem.features.onramp.mainv2.entity + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class OnrampNewAmountBlockUM( + val currencyUM: OnrampNewCurrencyUM, + val amountFieldModel: AmountFieldModel, + val secondaryFieldModel: OnrampNewAmountSecondaryFieldUM, +) + +internal data class OnrampNewCurrencyUM( + val unit: String, + val code: String, + val iconUrl: String?, + val precision: Int, + val onClick: () -> Unit, +) + +@Immutable +internal sealed interface OnrampNewAmountSecondaryFieldUM { + data object Loading : OnrampNewAmountSecondaryFieldUM + data class Content(val amount: TextReference) : OnrampNewAmountSecondaryFieldUM + data class Error(val error: TextReference) : OnrampNewAmountSecondaryFieldUM +} + +internal sealed interface OnrampV2AmountButtonUMState { + data class Loaded(val amountButtons: ImmutableList) : OnrampV2AmountButtonUMState + data object None : OnrampV2AmountButtonUMState +} + +internal data class OnrampAmountButtonUM( + val value: Int, + val currency: String, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt new file mode 100644 index 0000000000..dcb21bbf8a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt @@ -0,0 +1,13 @@ +package com.tangem.features.onramp.mainv2.entity + +import com.tangem.domain.onramp.model.OnrampCountry +import kotlinx.serialization.Serializable + +@Serializable +sealed interface OnrampV2MainBottomSheetConfig { + @Serializable + data class ConfirmResidency(val country: OnrampCountry) : OnrampV2MainBottomSheetConfig + + @Serializable + data object CurrenciesList : OnrampV2MainBottomSheetConfig +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt new file mode 100644 index 0000000000..0b6560e380 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt @@ -0,0 +1,43 @@ +package com.tangem.features.onramp.mainv2.entity + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed interface OnrampV2MainComponentUM { + + val topBarConfig: OnrampV2MainTopBarUM + val continueButtonConfig: ContinueButtonUM + val errorNotification: NotificationUM? + + data class InitialLoading( + override val topBarConfig: OnrampV2MainTopBarUM, + override val continueButtonConfig: ContinueButtonUM, + override val errorNotification: NotificationUM?, + ) : OnrampV2MainComponentUM + + data class Content( + override val topBarConfig: OnrampV2MainTopBarUM, + override val continueButtonConfig: ContinueButtonUM, + override val errorNotification: NotificationUM?, + val amountBlockState: OnrampNewAmountBlockUM, + val offersBlockState: OnrampOffersBlockUM, + val onrampAmountButtonUMState: OnrampV2AmountButtonUMState, + val onrampProviderState: OnrampV2ProvidersUM, + ) : OnrampV2MainComponentUM +} + +internal data class ContinueButtonUM( + val text: TextReference, + val onClick: () -> Unit, + val enabled: Boolean, + val showProgress: Boolean = false, +) + +internal data class OnrampV2MainTopBarUM( + val title: TextReference, + val startButtonUM: TopAppBarButtonUM, + val endButtonUM: TopAppBarButtonUM, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt new file mode 100644 index 0000000000..750e9ba8f3 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.onramp.mainv2.entity + +import com.tangem.domain.onramp.model.OnrampPaymentMethod + +sealed interface OnrampV2ProvidersUM { + + data object Empty : OnrampV2ProvidersUM + + data object Loading : OnrampV2ProvidersUM + + data class Content( + val providerId: String, + val paymentMethod: OnrampPaymentMethod, + ) : OnrampV2ProvidersUM +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt new file mode 100644 index 0000000000..2f8f0f4186 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt @@ -0,0 +1,84 @@ +package com.tangem.features.onramp.mainv2.entity.converter + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.onramp.main.entity.OnrampIntents +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +internal class OnrampV2AmountFieldChangeConverter( + private val currentStateProvider: Provider, + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, + private val onrampIntents: OnrampIntents, + private val cryptoCurrency: CryptoCurrency, +) : Converter { + + override fun convert(value: String): OnrampV2MainComponentUM { + val state = currentStateProvider() + if (state !is OnrampV2MainComponentUM.Content) return state + + if (value.isEmpty()) return state.emptyState() + + val amountState = state.amountBlockState + val amountTextField = amountState.amountFieldModel + val fiatDecimal = value.parseBigDecimalOrNull() ?: BigDecimal.ZERO + val amountFieldModel = amountState.amountFieldModel.copy( + fiatValue = value, + fiatAmount = amountTextField.fiatAmount.copy(value = fiatDecimal), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + return state.copy( + amountBlockState = amountState.copy( + amountFieldModel = amountFieldModel, + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, + ), + continueButtonConfig = state.continueButtonConfig.copy(enabled = false), + onrampProviderState = OnrampV2ProvidersUM.Loading, + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + ) + } + + private fun OnrampV2MainComponentUM.Content.emptyState(): OnrampV2MainComponentUM.Content { + val amountFieldModel = amountBlockState.amountFieldModel.copy( + value = "", + fiatValue = "", + cryptoAmount = amountBlockState.amountFieldModel.cryptoAmount.copy(value = BigDecimal.ZERO), + fiatAmount = amountBlockState.amountFieldModel.fiatAmount.copy(value = BigDecimal.ZERO), + isError = false, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.None, + keyboardType = KeyboardType.Number, + ), + ) + return copy( + amountBlockState = amountBlockState.copy( + amountFieldModel = amountFieldModel, + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ), + continueButtonConfig = continueButtonConfig.copy(enabled = false), + offersBlockState = OnrampOffersBlockUM.Empty, + onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton( + currencySymbol = amountBlockState.currencyUM.unit, + currencyCode = amountBlockState.currencyUM.code, + onAmountValueChanged = onrampIntents::onAmountValueChanged, + ), + onrampProviderState = OnrampV2ProvidersUM.Empty, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt new file mode 100644 index 0000000000..4bf1a7285a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt @@ -0,0 +1,35 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM +import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState +import kotlinx.collections.immutable.toPersistentList + +internal class OnrampAmountButtonUMStateFactory { + + private val defaultPreselectedAmount = listOf(50, 100, 200, 300, 500) + + fun createOnrampAmountActionButton( + currencyCode: String, + currencySymbol: String, + onAmountValueChanged: (String) -> Unit, + ): OnrampV2AmountButtonUMState { + return when (currencyCode) { + USD_CODE, EUR_CODE -> { + val buttons = defaultPreselectedAmount.map { value -> + OnrampAmountButtonUM( + value = value, + currency = currencySymbol, + onClick = { onAmountValueChanged(value.toString()) }, + ) + }.toPersistentList() + OnrampV2AmountButtonUMState.Loaded(buttons) + } + else -> OnrampV2AmountButtonUMState.None + } + } + + companion object { + private const val USD_CODE = "USD" + private const val EUR_CODE = "EUR" + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt new file mode 100644 index 0000000000..3c7a477fd1 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt @@ -0,0 +1,222 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.tokens.model.AmountType +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.main.entity.OnrampIntents +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter +import com.tangem.utils.Provider +import java.math.BigDecimal + +internal class OnrampV2AmountStateFactory( + private val currentStateProvider: Provider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val onrampIntents: OnrampIntents, + private val cryptoCurrency: CryptoCurrency, + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, +) { + + private val onrampAmountFieldChangeConverter: OnrampV2AmountFieldChangeConverter by lazy( + mode = LazyThreadSafetyMode.NONE, + ) { + OnrampV2AmountFieldChangeConverter( + currentStateProvider = currentStateProvider, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + onrampIntents = onrampIntents, + cryptoCurrency = cryptoCurrency, + ) + } + + fun getOnAmountValueChange(value: String): OnrampV2MainComponentUM { + return onrampAmountFieldChangeConverter.convert(value) + } + + fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + + return currentState.copy( + amountBlockState = amountState.copy( + currencyUM = amountState.currencyUM.copy( + unit = currency.unit, + code = currency.code, + iconUrl = currency.image, + precision = currency.precision, + ), + amountFieldModel = amountState.amountFieldModel.copy( + isError = false, + fiatAmount = amountState.amountFieldModel.fiatAmount.copy( + currencySymbol = currency.unit, + decimals = currency.precision, + type = AmountType.FiatType(currency.code), + ), + ), + ), + onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton( + currencyCode = currency.code, + currencySymbol = currency.unit, + onAmountValueChanged = onrampIntents::onAmountValueChanged, + ), + ) + } + + fun getAmountSecondaryLoadingState(): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + + return currentState.copy( + amountBlockState = amountState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, + ), + offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false), + continueButtonConfig = currentState.continueButtonConfig.copy(enabled = false), + errorNotification = null, + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + onrampProviderState = OnrampV2ProvidersUM.Loading, + ) + } + + fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState + + return currentState.copy( + amountBlockState = amountState.copy( + amountFieldModel = amountState.amountFieldModel.copy(isError = false), + secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel, + ), + continueButtonConfig = currentState.continueButtonConfig.copy( + enabled = quote is OnrampQuote.Data, + onClick = onrampIntents::onContinueClick, + ), + errorNotification = null, + ) + } + + fun getUpdatedProviderState(selectedQuote: OnrampQuote): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + analyticsEventHandler.send( + OnrampAnalyticsEvent.ProviderCalculated( + providerName = selectedQuote.provider.info.name, + tokenSymbol = cryptoCurrency.symbol, + paymentMethod = selectedQuote.paymentMethod.name, + ), + ) + return currentState.copy( + onrampProviderState = selectedQuote.toProviderBlockState(), + ) + } + + fun getAmountSecondaryResetState(): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + val amountState = currentState.amountBlockState + + if (amountState.secondaryFieldModel is OnrampNewAmountSecondaryFieldUM.Content) return currentState + + return currentState.copy( + amountBlockState = amountState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + amount = stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ), + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + errorNotification = null, + ) + } + + fun getShowProvidersState(): OnrampV2MainComponentUM { + val currentState = currentStateProvider() + if (currentState !is OnrampV2MainComponentUM.Content) return currentState + + return when (currentState.offersBlockState) { + is OnrampOffersBlockUM.Content -> { + currentState.copy( + offersBlockState = currentState.offersBlockState.copy(isBlockVisible = true), + ) + } + OnrampOffersBlockUM.Empty, + is OnrampOffersBlockUM.Loading, + -> currentState + } + } + + private fun OnrampQuote.toProviderBlockState(): OnrampV2ProvidersUM { + return OnrampV2ProvidersUM.Content( + paymentMethod = paymentMethod, + providerId = provider.id, + ) + } + + private fun OnrampQuote.toSecondaryFieldUiModel( + amountState: OnrampNewAmountBlockUM, + ): OnrampNewAmountSecondaryFieldUM? { + return when (this) { + is OnrampQuote.Error -> null + is OnrampQuote.Data -> { + val amount = toAmount.value.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + } + val contentAmount = combinedReference(stringReference("\u007E"), stringReference(amount)) + OnrampNewAmountSecondaryFieldUM.Content(contentAmount) + } + is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState) + } + } + + private fun OnrampQuote.AmountError.toSecondaryFieldUiModel( + amountState: OnrampNewAmountBlockUM, + ): OnrampNewAmountSecondaryFieldUM.Error { + val amount = error.requiredAmount.format { + fiat( + fiatCurrencyCode = amountState.amountFieldModel.fiatAmount.currencySymbol, + fiatCurrencySymbol = amountState.amountFieldModel.fiatAmount.currencySymbol, + ) + } + + val errorTextRes = when (error) { + is OnrampError.AmountError.TooBigError -> { + analyticsEventHandler.send(OnrampAnalyticsEvent.MaxAmountError) + R.string.onramp_max_amount_restriction + } + is OnrampError.AmountError.TooSmallError -> { + analyticsEventHandler.send(OnrampAnalyticsEvent.MinAmountError) + R.string.onramp_min_amount_restriction + } + } + + return OnrampNewAmountSecondaryFieldUM.Error( + resourceReference( + errorTextRes, + wrappedList(amount), + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt new file mode 100644 index 0000000000..8e37235b44 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt @@ -0,0 +1,205 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.tokens.model.AmountType +import com.tangem.domain.tokens.model.convertToAmount +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.main.entity.OnrampIntents +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.utils.Provider +import java.math.BigDecimal + +internal class OnrampV2StateFactory( + private val currentStateProvider: Provider, + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, + private val cryptoCurrency: CryptoCurrency, + private val onrampIntents: OnrampIntents, +) { + + fun getInitialState( + currency: String, + onClose: () -> Unit, + openSettings: () -> Unit, + ): OnrampV2MainComponentUM.InitialLoading { + return OnrampV2MainComponentUM.InitialLoading( + errorNotification = null, + topBarConfig = OnrampV2MainTopBarUM( + title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")), + startButtonUM = TopAppBarButtonUM.Close( + onCloseClick = onClose, + enabled = true, + ), + endButtonUM = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_more_vertical_24, + onClicked = openSettings, + enabled = false, + ), + ), + continueButtonConfig = ContinueButtonUM( + text = resourceReference(R.string.common_continue), + onClick = {}, + enabled = false, + ), + ) + } + + fun getReadyState(currency: OnrampCurrency): OnrampV2MainComponentUM.Content { + val state = currentStateProvider() + + val endButton = when (val button = state.topBarConfig.endButtonUM) { + is TopAppBarButtonUM.Icon -> button.copy(enabled = true) + is TopAppBarButtonUM.Text -> button.copy(enabled = true) + } + + val initialAmountBlockState = getInitialAmountBlockState(currency) + + return OnrampV2MainComponentUM.Content( + topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), + continueButtonConfig = ContinueButtonUM( + text = resourceReference(R.string.common_continue), + onClick = onrampIntents::onContinueClick, + enabled = false, + ), + amountBlockState = initialAmountBlockState, + offersBlockState = OnrampOffersBlockUM.Empty, + errorNotification = null, + onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton( + currencyCode = currency.code, + currencySymbol = currency.unit, + onAmountValueChanged = onrampIntents::onAmountValueChanged, + ), + onrampProviderState = OnrampV2ProvidersUM.Empty, + ) + } + + fun getOnrampErrorState(onrampError: OnrampError): OnrampV2MainComponentUM { + return when (onrampError) { + OnrampError.PairsNotFound -> getNoPairsErrorState() + is OnrampError.DataError -> getErrorState( + errorCode = onrampError.code, + onRefresh = onrampIntents::onRefresh, + ) + is OnrampError.DomainError -> getErrorState(onRefresh = onrampIntents::onRefresh) + is OnrampError.AmountError.TooBigError, + is OnrampError.AmountError.TooSmallError, + OnrampError.RedirectError.VerificationFailed, + OnrampError.RedirectError.WrongRequestId, + -> currentStateProvider() // ignore error state + } + } + + private fun getNoPairsErrorState(): OnrampV2MainComponentUM { + val state = currentStateProvider() + val contentState = state as? OnrampV2MainComponentUM.Content ?: return state + + return contentState.copy( + continueButtonConfig = contentState.continueButtonConfig.copy(enabled = false), + amountBlockState = contentState.amountBlockState.copy( + amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true), + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Error( + error = resourceReference(R.string.onramp_no_available_providers), + ), + ), + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + offersBlockState = OnrampOffersBlockUM.Empty, + ) + } + + fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM { + val state = currentStateProvider() + val endButton = when (val button = state.topBarConfig.endButtonUM) { + is TopAppBarButtonUM.Icon -> button.copy(enabled = true) + is TopAppBarButtonUM.Text -> button.copy(enabled = true) + } + + return when (state) { + is OnrampV2MainComponentUM.Content -> state.copy( + topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), + continueButtonConfig = state.continueButtonConfig.copy(enabled = false), + amountBlockState = state.amountBlockState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ), + offersBlockState = OnrampOffersBlockUM.Empty, + errorNotification = NotificationUM.Warning.OnrampErrorNotification( + errorCode = errorCode, + onRefresh = onRefresh, + ), + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + onrampProviderState = OnrampV2ProvidersUM.Empty, + ) + is OnrampV2MainComponentUM.InitialLoading -> state.copy( + errorNotification = NotificationUM.Warning.OnrampErrorNotification( + errorCode = errorCode, + onRefresh = onRefresh, + ), + ) + } + } + + private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampNewAmountBlockUM { + return OnrampNewAmountBlockUM( + currencyUM = OnrampNewCurrencyUM( + code = currency.code, + iconUrl = currency.image, + precision = currency.precision, + onClick = onrampIntents::openCurrenciesList, + unit = currency.unit, + ), + amountFieldModel = AmountFieldModel( + value = "", + fiatValue = "", + onValueChange = onrampIntents::onAmountValueChanged, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.None, + keyboardType = KeyboardType.Number, + ), + keyboardActions = KeyboardActions(), + isFiatValue = true, + cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency), + fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency), + isError = false, + isWarning = false, + error = TextReference.EMPTY, + isFiatUnavailable = false, + isValuePasted = false, + onValuePastedTriggerDismiss = {}, + ), + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( + stringReference( + BigDecimal.ZERO.format { + crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) + }, + ), + ), + ) + } + + private fun BigDecimal.convertToFiatAmount(currency: OnrampCurrency): Amount = Amount( + currencySymbol = currency.unit, + value = this, + decimals = currency.precision, + type = AmountType.FiatType(currency.code), + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt new file mode 100644 index 0000000000..8d6615a675 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -0,0 +1,359 @@ +package com.tangem.features.onramp.mainv2.model + +import androidx.compose.runtime.mutableStateOf +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.ui.components.fields.InputManager +import com.tangem.domain.onramp.* +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.onramp.main.entity.OnrampIntents +import com.tangem.features.onramp.main.entity.OnrampLastUpdate +import com.tangem.features.onramp.mainv2.OnrampV2MainComponent +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory +import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2AmountStateFactory +import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2StateFactory +import com.tangem.features.onramp.utils.sendOnrampErrorEvent +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.isNullOrZero +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList") +internal class OnrampV2MainComponentModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val router: Router, + private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase, + private val getOnrampCountryUseCase: GetOnrampCountryUseCase, + private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase, + private val fetchQuotesUseCase: OnrampFetchQuotesUseCase, + private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase, + private val fetchPairsUseCase: OnrampFetchPairsUseCase, + private val amountInputManager: InputManager, + paramsContainer: ParamsContainer, + getWalletsUseCase: GetWalletsUseCase, +) : Model(), OnrampIntents { + + val params = paramsContainer.require() + + private var loadQuotesJob: Job? = null + + private val lastUpdateState = mutableStateOf(null) + + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampAmountButtonUMStateFactory() + } + + private val stateFactory = OnrampV2StateFactory( + currentStateProvider = Provider { _state.value }, + cryptoCurrency = params.cryptoCurrency, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + + private val amountStateFactory = OnrampV2AmountStateFactory( + currentStateProvider = Provider { _state.value }, + analyticsEventHandler = analyticsEventHandler, + onrampIntents = this, + cryptoCurrency = params.cryptoCurrency, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + + private val _state: MutableStateFlow = MutableStateFlow( + value = stateFactory.getInitialState( + currency = params.cryptoCurrency.name, + onClose = ::onCloseClick, + openSettings = ::openSettings, + ), + ) + val state: StateFlow get() = _state.asStateFlow() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + + init { + modelScope.launch { + clearOnrampCacheUseCase() + } + + sendScreenOpenAnalytics() + checkResidenceCountry() + subscribeToAmountChanges() + subscribeToCountryAndCurrencyUpdates() + subscribeToQuotesUpdate() + } + + override fun onDestroy() { + modelScope.launch { clearOnrampCacheUseCase.invoke() } + loadQuotesJob?.cancel() + super.onDestroy() + } + + override fun onAmountValueChanged(value: String) { + _state.update { amountStateFactory.getOnAmountValueChange(value) } + modelScope.launch { amountInputManager.update(value) } + } + + override fun openSettings() { + params.openSettings + } + + override fun openCurrenciesList() { + analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened) + bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList) + } + + override fun onBuyClick(quote: OnrampProviderWithQuote.Data) { + // TODO in [REDACTED_TASK_KEY] to be continued + } + + override fun openProviders() { + // TODO in [REDACTED_TASK_KEY] to be continued + } + + override fun onRefresh() { + _state.update { + stateFactory.getInitialState( + currency = params.cryptoCurrency.name, + onClose = router::pop, + openSettings = ::openSettings, + ) + } + loadQuotesJob?.cancel() + modelScope.launch { + clearOnrampCacheUseCase.invoke() + checkResidenceCountry() + } + } + + override fun onLinkClick(link: String) { + // TODO in [REDACTED_TASK_KEY] will be deleted + } + + override fun onContinueClick() { + val currentState = _state.value + if (currentState is OnrampV2MainComponentUM.Content) { + _state.update { amountStateFactory.getShowProvidersState() } + } + } + + private fun checkResidenceCountry() { + modelScope.launch { + checkOnrampAvailabilityUseCase(userWallet) + .onRight(::handleOnrampAvailability) + .onLeft(::handleOnrampError) + } + } + + private fun handleOnrampAvailability(availability: OnrampAvailability) { + when (availability) { + is OnrampAvailability.Available -> Unit + is OnrampAvailability.ConfirmResidency, + is OnrampAvailability.NotSupported, + -> bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.ConfirmResidency(availability.country)) + } + } + + private fun onCloseClick() { + analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp) + router.pop() + } + + private fun subscribeToAmountChanges() = modelScope.launch { + amountInputManager.query + .filter(String::isNotEmpty) + .collectLatest { _ -> + _state.update { amountStateFactory.getAmountSecondaryLoadingState() } + loadQuotes() + } + } + + private fun subscribeToCountryAndCurrencyUpdates() { + getOnrampCountryUseCase.invoke() + .onEach { maybeCountry -> + maybeCountry.fold( + ifLeft = ::handleOnrampError, + ifRight = { country -> + if (country == null) return@onEach + _state.update { + when (it) { + is OnrampV2MainComponentUM.Content -> { + amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) + } + is OnrampV2MainComponentUM.InitialLoading -> { + stateFactory.getReadyState(country.defaultCurrency) + } + } + } + updatePairsAndQuotes() + }, + ) + } + .launchIn(modelScope) + } + + private fun subscribeToQuotesUpdate() { + getOnrampQuotesUseCase.invoke() + .conflate() + .onEach { maybeQuotes -> + maybeQuotes.fold( + ifLeft = ::handleOnrampError, + ifRight = ::handleQuoteResult, + ) + } + .launchIn(modelScope) + } + + private fun handleQuoteResult(quotes: List) { + sendOnrampQuotesErrorAnalytic(quotes) + + val quote = selectOrUpdateQuote(quotes) + + if (quote == null) { + _state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } + lastUpdateState.value = null + return + } + _state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) } + } + + private fun selectOrUpdateQuote(quotes: List): OnrampQuote? { + val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error } + + // Check if amount, country or currency has changed + val newQuote = if (checkLastInputState(quoteToCheck)) { + quoteToCheck + } else { + val state = state.value as? OnrampV2MainComponentUM.Content + val providerState = state?.onrampProviderState as? OnrampV2ProvidersUM.Content + + // Get current selected quote to update + val lastSelectedQuote = quotes.firstOrNull { + it.provider.id == providerState?.providerId && + it.paymentMethod.id == providerState.paymentMethod.id + } + + if (lastSelectedQuote is OnrampQuote.Error) { + quoteToCheck + } else { + lastSelectedQuote + } + } + newQuote?.let { updateProvider(newQuote) } + + return newQuote + } + + private fun onRetryQuotes() { + _state.update { + (it as? OnrampV2MainComponentUM.Content)?.copy( + errorNotification = null, + onrampProviderState = OnrampV2ProvidersUM.Loading, + offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false), + amountBlockState = it.amountBlockState.copy( + secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, + ), + ) ?: it + } + loadQuotes() + } + + private suspend fun updatePairsAndQuotes() { + val state = state.value as? OnrampV2MainComponentUM.Content + + if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { + _state.update { amountStateFactory.getAmountSecondaryLoadingState() } + } + fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold( + ifLeft = ::handleOnrampError, + ifRight = { + _state.update { + if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { + return@fold + } else { + amountStateFactory.getAmountSecondaryResetState() + } + } + }, + ) + loadQuotes() + } + + private fun loadQuotes() { + loadQuotesJob?.cancel() + loadQuotesJob = modelScope.launch { + runCatching { + val content = state.value as? OnrampV2MainComponentUM.Content ?: return@runCatching + if (content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) return@runCatching + fetchQuotesUseCase.invoke( + userWallet = userWallet, + amount = content.amountBlockState.amountFieldModel.fiatAmount, + cryptoCurrency = params.cryptoCurrency, + ).onLeft(::handleOnrampError) + } + } + } + + private fun handleOnrampError(onrampError: OnrampError) { + Timber.e(onrampError.toString()) + _state.update { stateFactory.getOnrampErrorState(onrampError) } + } + + private fun updateProvider(quote: OnrampQuote) { + lastUpdateState.value = OnrampLastUpdate( + quote.fromAmount, + quote.countryCode, + ) + + _state.update { + amountStateFactory.getUpdatedProviderState(selectedQuote = quote) + } + } + + private fun sendOnrampQuotesErrorAnalytic(quotes: List) { + quotes.forEach { errorState -> + when (errorState) { + is OnrampQuote.Error -> analyticsEventHandler.sendOnrampErrorEvent( + error = errorState.error, + tokenSymbol = params.cryptoCurrency.symbol, + providerName = errorState.provider.info.name, + paymentMethod = errorState.paymentMethod.name, + ) + is OnrampQuote.AmountError -> analyticsEventHandler.sendOnrampErrorEvent( + error = errorState.error, + tokenSymbol = params.cryptoCurrency.symbol, + providerName = errorState.provider.info.name, + paymentMethod = errorState.paymentMethod.name, + ) + else -> Unit + } + } + } + + private fun checkLastInputState(quote: OnrampQuote?): Boolean { + return lastUpdateState.value?.lastAmount != quote?.fromAmount || + lastUpdateState.value?.lastCountryString != quote?.countryCode + } + + private fun sendScreenOpenAnalytics() { + analyticsEventHandler.send( + OnrampAnalyticsEvent.ScreenOpened( + source = params.source, + tokenSymbol = params.cryptoCurrency.symbol, + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt new file mode 100644 index 0000000000..5668fac482 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt @@ -0,0 +1,130 @@ +package com.tangem.features.onramp.mainv2.ui + +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.Keyboard +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM +import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM + +@Composable +internal fun OnrampFooterContent( + state: OnrampV2MainComponentUM.Content, + boxScope: BoxScope, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + boxScope.apply { + AnimatedVisibility( + modifier = Modifier + .imePadding() + .align(Alignment.BottomCenter), + visible = state.offersBlockState.isBlockVisible.not(), + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + label = "Footer block animation", + ) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResourceSafe(id = R.string.common_continue), + onClick = { + state.continueButtonConfig.onClick() + keyboardController?.hide() + }, + enabled = state.continueButtonConfig.enabled, + ) + SpacerH(16.dp) + OnrampAmountButtons(state = state.onrampAmountButtonUMState) + } + } + } +} + +@Composable +private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) { + val keyboard by keyboardAsState() + + AnimatedVisibility( + visible = state is OnrampV2AmountButtonUMState.Loaded, + enter = fadeIn(), + exit = fadeOut(), + ) { + when (state) { + is OnrampV2AmountButtonUMState.Loaded -> { + if (keyboard is Keyboard.Opened) { + LazyRow( + modifier = Modifier.background(color = TangemTheme.colors.button.secondary), + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 8.dp, + ), + state = rememberLazyListState(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.amountButtons, + key = OnrampAmountButtonUM::value, + ) { + AmountButton(button = it) + } + } + } + } + OnrampV2AmountButtonUMState.None -> Unit + } + } +} + +@Composable +private fun AmountButton(button: OnrampAmountButtonUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .sizeIn(minHeight = 24.dp, minWidth = 62.dp) + .background( + color = TangemTheme.colors.field.primary, + shape = RoundedCornerShape(16.dp), + ) + .clickable(onClick = button.onClick) + .padding(vertical = 4.dp, horizontal = 20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "${button.value}${button.currency}", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt new file mode 100644 index 0000000000..cf0d2ca599 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt @@ -0,0 +1,141 @@ +package com.tangem.features.onramp.mainv2.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM + +@Composable +internal fun OnrampNewMainScreen(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier.systemBarsPadding(), + topBar = { + TangemTopAppBar( + startButton = state.topBarConfig.startButtonUM, + endButton = state.topBarConfig.endButtonUM, + title = state.topBarConfig.title.resolveReference(), + ) + }, + contentWindowInsets = WindowInsetsZero, + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + OnrampNewMainComponentContent( + state = state, + modifier = Modifier.padding(scaffoldPaddings), + ) + } +} + +@Composable +internal fun OnrampNewMainComponentContent(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state) { + is OnrampV2MainComponentUM.InitialLoading -> InitialLoading(state = state) + is OnrampV2MainComponentUM.Content -> Content(state = state) + } + } + + if (state is OnrampV2MainComponentUM.Content) { + OnrampFooterContent( + state = state, + boxScope = this, + ) + } + } +} + +@Composable +private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + OnrampAmountContentLoading() + if (state.errorNotification != null) Notification(config = state.errorNotification.config) + } +} + +@Composable +private fun OnrampAmountContentLoading() { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16)) + .background(TangemTheme.colors.background.action) + .padding(vertical = TangemTheme.dimens.spacing28), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .size(width = 76.dp, height = 20.dp), + radius = TangemTheme.dimens.radius4, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12) + .size(width = 136.dp, height = 44.dp), + radius = TangemTheme.dimens.radius4, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing8) + .size(width = 52.dp, height = 16.dp), + radius = TangemTheme.dimens.radius4, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing20) + .size(width = 84.dp, height = 28.dp), + radius = TangemTheme.dimens.radius14, + ) + } +} + +@Composable +private fun Content(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .navigationBarsPadding() + .padding( + bottom = 76.dp, + start = 16.dp, + end = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + OnrampV2AmountContent(state = state) + + OnrampOffersContent(state = state.offersBlockState) + + if (state.errorNotification != null) Notification(config = state.errorNotification.config) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt new file mode 100644 index 0000000000..6b35933d9e --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt @@ -0,0 +1,32 @@ +package com.tangem.features.onramp.mainv2.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM + +@Composable +internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { + AnimatedVisibility( + visible = state.isBlockVisible, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + label = "Offers block animation", + ) { + Text( + text = "Some offers", + style = TangemTheme.typography.head, + ) + // TODO in [REDACTED_TASK_KEY] to be continued + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt new file mode 100644 index 0000000000..284e2e04cc --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt @@ -0,0 +1,199 @@ +package com.tangem.features.onramp.mainv2.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.fields.AmountTextField +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags +import com.tangem.core.ui.utils.rememberDecimalFormat +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampNewAmountSecondaryFieldUM +import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM +import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM + +@Composable +internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) { + val padding = remember(state.offersBlockState.isBlockVisible) { + if (state.offersBlockState.isBlockVisible) { + 22.dp + } else { + 46.dp + } + } + + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.action, + shape = RoundedCornerShape(size = TangemTheme.dimens.radius16), + ) + .padding(vertical = padding) + .animateContentSize(animationSpec = tween(durationMillis = 300)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OnrampHeaderTitle() + + SpacerH(12.dp) + + OnrampAmountField( + amountField = state.amountBlockState.amountFieldModel, + currencyCode = state.amountBlockState.currencyUM.code, + ) + + SpacerH(8.dp) + + OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel) + + SpacerH(20.dp) + + OnrampCurrencyIcon(currencyUM = state.amountBlockState.currencyUM) + } +} + +@Composable +private fun OnrampHeaderTitle() { + Text( + text = stringResourceSafe(R.string.onramp_you_will_pay_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: String) { + val decimalFormat = rememberDecimalFormat() + val requester = remember { FocusRequester() } + AmountTextField( + value = amountField.fiatValue, + decimals = amountField.fiatAmount.decimals, + visualTransformation = AmountVisualTransformation( + decimals = amountField.fiatAmount.decimals, + symbol = currencyCode, + currencyCode = currencyCode, + decimalFormat = decimalFormat, + symbolColor = TangemTheme.colors.text.disabled, + ), + onValueChange = amountField.onValueChange, + keyboardOptions = amountField.keyboardOptions, + keyboardActions = amountField.keyboardActions, + textStyle = TangemTheme.typography.head.copy( + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ), + isEnabled = !amountField.isError, + isAutoResize = true, + isValuePasted = amountField.isValuePasted, + onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss, + modifier = Modifier + .focusRequester(requester) + .padding( + top = TangemTheme.dimens.spacing24, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ) + .requiredHeightIn(min = TangemTheme.dimens.size32) + .testTag(BuyTokenDetailsScreenTestTags.FIAT_AMOUNT_TEXT_FIELD), + ) + + LaunchedEffect(key1 = Unit) { + requester.requestFocus() + } +} + +@Composable +private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ) + .testTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT), + contentAlignment = Alignment.Center, + ) { + when (state) { + is OnrampNewAmountSecondaryFieldUM.Content -> Text( + text = state.amount.resolveReference(), + style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr), + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + is OnrampNewAmountSecondaryFieldUM.Error -> Text( + text = state.error.resolveReference(), + color = TangemTheme.colors.text.warning, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + ) + is OnrampNewAmountSecondaryFieldUM.Loading -> TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier.width(TangemTheme.dimens.size62), + ) + } + } +} + +@Composable +private fun OnrampCurrencyIcon(currencyUM: OnrampNewCurrencyUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(RoundedCornerShape(14.dp)) + .background(TangemTheme.colors.button.secondary) + .clickable(onClick = currencyUM.onClick) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AsyncImage( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .testTag(BuyTokenDetailsScreenTestTags.FIAT_CURRENCY_ICON), + model = currencyUM.iconUrl, + contentDescription = null, + ) + Text( + text = currencyUM.code, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + ) + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size16) + .testTag(BuyTokenDetailsScreenTestTags.EXPAND_FIAT_LIST_BUTTON), + painter = painterResource(id = R.drawable.ic_chevron_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt deleted file mode 100644 index 5eab7b94c5..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.onramp.newmain - -internal interface OnrampNewMainFeatureToggle { - val isOnrampNewMainEnabled: Boolean -} \ No newline at end of file From 02f927fd48383684915d3adddb4992d10b0e34eb Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 2 Sep 2025 17:33:18 +0300 Subject: [PATCH 041/132] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 4 + .../com/tangem/common/utils/NetworkUtils.kt | 105 ++++++++++ .../tangem/screens/MainScreenPageObject.kt | 4 + .../WalletConnectBottonSheetPageObject.kt | 108 +++++++++++ ...lletConnectDetailsBottonSheetPageObject.kt | 107 +++++++++++ .../tangem/screens/WalletConnectPageObject.kt | 59 ++++++ .../kotlin/com/tangem/steps/BaseScenarios.kt | 38 ++++ .../com/tangem/steps/DeepLinksScenarios.kt | 15 ++ .../tangem/steps/WalletConnectScenarios.kt | 139 ++++++++++++++ .../com/tangem/tests/WalletConnectTest.kt | 181 ++++++++++++++++++ .../modal/TangemModalBottomSheetTitle.kt | 13 +- .../test/WalletConnectBottomSheetTestTags.kt | 22 +++ ...WalletConnectDetailsBottomSheetTestTags.kt | 16 ++ .../ui/test/WalletConnectScreenTestTags.kt | 10 + .../connections/ui/InternalComponents.kt | 30 ++- .../connections/ui/WcAppInfoBS.kt | 39 +++- .../connections/ui/WcConnectedAppInfoBS.kt | 18 +- .../connections/ui/WcConnectionsContent.kt | 19 +- 18 files changed, 898 insertions(+), 29 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/WalletConnectDetailsBottonSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/steps/BaseScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/steps/WalletConnectScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectBottomSheetTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectDetailsBottomSheetTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 7fd98efe81..7ab38e88e2 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -126,6 +126,10 @@ abstract class BaseTestCase : TestCase( with(featureTogglesManager as MutableFeatureTogglesManager) { changeToggle("WALLET_CONNECT_REDESIGN_ENABLED", true) changeToggle("WALLET_BALANCE_FETCHER_ENABLED", true) + changeToggle("SEND_VIA_SWAP_ENABLED", true) + changeToggle("SWAP_REDESIGN_ENABLED", true) + changeToggle("SEND_REDESIGN_ENABLED", true) + changeToggle("NEW_ONRAMP_MAIN_ENABLED", true) } } } diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt new file mode 100644 index 0000000000..da1573d026 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt @@ -0,0 +1,105 @@ +package com.tangem.common.utils + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * + */ +fun getWcUri( + network: String = "ethereum", + baseUrl: String = "[REDACTED_ENV_URL]" +): String? { + Timber.i("Getting WC URI for network: $network") + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) // Таймаут подключения + .readTimeout(60, TimeUnit.SECONDS) // Таймаут чтения ответа + .writeTimeout(30, TimeUnit.SECONDS) // Таймаут записи + .callTimeout(90, TimeUnit.SECONDS) // Общий таймаут запроса + .build() + + val request = Request.Builder() + .url("$baseUrl/wc_uri?network=$network") + .get() + .build() + + return try { + client.newCall(request).execute().use { response -> + Timber.i("Response code: ${response.code}") + + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + Timber.i("Response body: $body") + + val jsonObject = JSONObject(body) + + if (jsonObject.getBoolean("success")) { + val wcUri = jsonObject.getString("wcUri") + Timber.i("Got WC URI successfully: $wcUri") + + wcUri + } else { + Timber.e("API returned error: ${jsonObject.optString("error", "Unknown")}") + null + } + } else { + val errorBody = response.body?.string() ?: "No error body" + Timber.e("Request failed: ${response.code}, body: $errorBody") + null + } + } + } catch (e: Exception) { + Timber.e(e, "Error getting WC URI") + null + } +} + +fun checkServiceHealth( + baseUrl: String = "[REDACTED_ENV_URL]" +): String? { + Timber.i("Checking service health") + + val client = OkHttpClient() + val request = Request.Builder() + .url("$baseUrl/health") + .get() + .build() + + return try { + client.newCall(request).execute().use { response -> + Timber.i("Response code: ${response.code}") + + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + Timber.i("Response body: $body") + + if (body.isEmpty()) { + Timber.e("Response body is empty") + return null + } + + val jsonObject = JSONObject(body) + val status = jsonObject.optString("status", "") + + if (status.isNotEmpty()) { + Timber.i("Got status successfully: $status") + status + } else { + Timber.e("Status field is missing or empty") + null + } + } else { + val errorBody = response.body?.string() ?: "No error body" + Timber.e("Request failed: ${response.code}, body: $errorBody") + null + } + } + } catch (e: Exception) { + Timber.e(e, "Error checking health") + null + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index ce3a3181b3..56742890d9 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -35,6 +35,10 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } ) + val screenContainer: KNode = child { + hasTestTag(MainScreenTestTags.SCREEN_CONTAINER) + } + val synchronizeAddressesButton: KNode = child { hasText(getResourceString(R.string.common_generate_addresses)) } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt new file mode 100644 index 0000000000..c111e92406 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt @@ -0,0 +1,108 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.features.walletconnect.impl.R as WalletConnectImplR + +class WalletConnectBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasText(getResourceString(WalletConnectImplR.string.wc_wallet_connect)) + hasTestTag(WalletConnectBottomSheetTestTags.TITLE) + useUnmergedTree = true + } + + val appIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_ICON) + useUnmergedTree = true + } + + val appName: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_NAME) + useUnmergedTree = true + } + + val approveIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APPROVE_ICON) + useUnmergedTree = true + } + + val appUrl: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_URL) + useUnmergedTree = true + } + + val connectionRequestIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_ICON) + useUnmergedTree = true + } + + val connectionRequestText: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_TEXT) + useUnmergedTree = true + } + + val connectionRequestChevron: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_CHEVRON) + useUnmergedTree = true + } + + val walletIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.WALLET_ICON) + useUnmergedTree = true + } + + val walletNameTitle: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.WALLET_NAME_TITLE) + useUnmergedTree = true + } + + val walletName: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.WALLET_NAME) + useUnmergedTree = true + } + + val networksIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_ICON) + useUnmergedTree = true + } + + val networksTitle: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_TITLE) + useUnmergedTree = true + } + + val networksIcons: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS) + useUnmergedTree = true + } + + val networksSelectorIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON) + useUnmergedTree = true + } + + val cancelButton: KNode = child { + hasText(getResourceString(R.string.common_cancel)) + hasTestTag(BaseButtonTestTags.TEXT) + useUnmergedTree = true + } + + val connectButton: KNode = child { + hasText(getResourceString(R.string.wc_common_connect)) + hasTestTag(BaseButtonTestTags.TEXT) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onWalletConnectBottomSheet(function: WalletConnectBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectDetailsBottonSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectDetailsBottonSheetPageObject.kt new file mode 100644 index 0000000000..e4eb48fde7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectDetailsBottonSheetPageObject.kt @@ -0,0 +1,107 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.features.walletconnect.impl.R as WalletConnectImplR + +class WalletConnectDetailsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.TITLE) + hasText(getResourceString(WalletConnectImplR.string.wc_connected_app_title)) + useUnmergedTree = true + } + + val date: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.DATE) + useUnmergedTree = true + } + + val closeButton: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val networksBlockTitle: KNode = child { + hasText(getResourceString(WalletConnectImplR.string.wc_connected_networks)) + useUnmergedTree = true + } + + val appIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_ICON) + useUnmergedTree = true + } + + val approveIcon: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APPROVE_ICON) + useUnmergedTree = true + } + + val appName: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_NAME) + useUnmergedTree = true + } + + val appUrl: KNode = child { + hasTestTag(WalletConnectBottomSheetTestTags.APP_URL) + useUnmergedTree = true + } + + val walletIcon: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.WALLET_ICON) + useUnmergedTree = true + } + + val walletTitle: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.WALLET_TITLE) + useUnmergedTree = true + } + + val walletName: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.WALLET_NAME) + useUnmergedTree = true + } + + val connectedNetworksTitle: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORKS_TITLE) + useUnmergedTree = true + } + + val connectedNetworkItem: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ITEM) + useUnmergedTree = true + } + + val connectedNetworkIcon: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ICON) + useUnmergedTree = true + } + + val connectedNetworkName: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_NAME) + useUnmergedTree = true + } + + val connectedNetworkSymbol: KNode = child { + hasTestTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_SYMBOL) + useUnmergedTree = true + } + + val disconnectButton: KNode = child { + hasText(getResourceString(WalletConnectImplR.string.common_disconnect)) + hasTestTag(BaseButtonTestTags.TEXT) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onWalletConnectDetailsBottomSheet(function: WalletConnectDetailsBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt new file mode 100644 index 0000000000..b9da100325 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt @@ -0,0 +1,59 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.WalletConnectScreenTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class WalletConnectPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasText(getResourceString(R.string.wc_connections)) + useUnmergedTree = true + } + + val moreButton: KNode = child { + hasTestTag(WalletConnectScreenTestTags.MORE_BUTTON) + useUnmergedTree = true + } + + val walletName: KNode = child { + hasTestTag(WalletConnectScreenTestTags.WALLET_NAME) + useUnmergedTree = true + } + + val appIcon: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APP_ICON) + useUnmergedTree = true + } + + val appName: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APP_NAME) + useUnmergedTree = true + } + + val approveIcon: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APPROVE_ICON) + useUnmergedTree = true + } + + val appUrl: KNode = child { + hasTestTag(WalletConnectScreenTestTags.APP_URL) + useUnmergedTree = true + } + + val newConnectionButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.wc_new_connection)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onWalletConnectScreen(function: WalletConnectPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/steps/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/steps/BaseScenarios.kt new file mode 100644 index 0000000000..54f4f8a915 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/steps/BaseScenarios.kt @@ -0,0 +1,38 @@ +package com.tangem.steps + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.scan.ProductType +import com.tangem.screens.onDisclaimerScreen +import com.tangem.screens.onMainScreen +import com.tangem.screens.onMarketsTooltipScreen +import com.tangem.screens.onStoriesScreen +import com.tangem.tap.domain.sdk.mocks.MockProvider +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openMainScreen(productType: ProductType? = null) { + if (productType != null) { + MockProvider.setMocks(productType) + } + step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + step("Click on 'Accept' button") { + onStoriesScreen { scanButton.clickWithAssertion() } + } + step("Assert main screen is displayed") { + onMainScreen { screenContainer.assertIsDisplayed() } + } + step("Assert main screen is displayed") { + onMarketsTooltipScreen { contentContainer.clickWithAssertion() } + } +} + +fun BaseTestCase.synchronizeAddresses(balance: String) { + step("Click on 'Synchronize addresses' button") { + onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + } + step("Assert wallet balance = '$balance'") { + onMainScreen { walletBalance().assertTextContains(balance) } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt b/app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt new file mode 100644 index 0000000000..832a1b9fe8 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt @@ -0,0 +1,15 @@ +package com.tangem.steps + +import android.content.Intent +import android.net.Uri +import androidx.test.core.app.ApplicationProvider + +fun openAppByDeepLink(deepLinkUri: String?) { + val deeplinkScheme = "tangem://wc?uri=" + val context = ApplicationProvider.getApplicationContext() + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplinkScheme + deepLinkUri)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/steps/WalletConnectScenarios.kt b/app/src/androidTest/kotlin/com/tangem/steps/WalletConnectScenarios.kt new file mode 100644 index 0000000000..77134c2ea3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/steps/WalletConnectScenarios.kt @@ -0,0 +1,139 @@ +package com.tangem.steps + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onWalletConnectBottomSheet +import com.tangem.screens.onWalletConnectDetailsBottomSheet +import com.tangem.screens.onWalletConnectScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkWalletConnectBottomSheet() { + step("Assert 'Wallet Connect' bottom sheet title is displayed") { + onWalletConnectBottomSheet { title.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet app icon is displayed") { + onWalletConnectBottomSheet { appIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet app name is displayed") { + onWalletConnectBottomSheet { appName.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet approve icon is displayed") { + onWalletConnectBottomSheet { approveIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet app URL is displayed") { + onWalletConnectBottomSheet { appUrl.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet connection request icon is displayed") { + onWalletConnectBottomSheet { connectionRequestIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet connection request text is displayed") { + onWalletConnectBottomSheet { connectionRequestText.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet connection request chevron is displayed") { + onWalletConnectBottomSheet { connectionRequestChevron.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet wallet icon is displayed") { + onWalletConnectBottomSheet { walletIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet wallet title is displayed") { + onWalletConnectBottomSheet { walletNameTitle.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet wallet name is displayed") { + onWalletConnectBottomSheet { walletName.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet networks icon is displayed") { + onWalletConnectBottomSheet { networksIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet networks title is displayed") { + onWalletConnectBottomSheet { networksTitle.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet right networks icons is displayed") { + onWalletConnectBottomSheet { networksIcons.assertIsDisplayed() } + } + step("'Wallet Connect' bottom sheet networks selector icon is displayed") { + onWalletConnectBottomSheet { networksSelectorIcon.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet 'Cancel' button is displayed") { + onWalletConnectBottomSheet { cancelButton.assertIsDisplayed() } + } + step("Assert 'Wallet Connect' bottom sheet 'Connect' button is displayed") { + onWalletConnectBottomSheet { connectButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkWalletConnectScreen() { + step("Assert 'Wallet Connect' title is displayed") { + onWalletConnectScreen { title.assertIsDisplayed() } + } + step("Assert 'More' button is displayed") { + onWalletConnectScreen { moreButton.assertIsDisplayed() } + } + step("Assert wallet name is displayed") { + onWalletConnectScreen { walletName.assertIsDisplayed() } + } + step("Assert app icon is displayed") { + onWalletConnectScreen { appIcon.assertIsDisplayed() } + } + step("Assert app name is displayed") { + onWalletConnectScreen { appName.assertIsDisplayed() } + } + step("Assert approve icon is displayed") { + onWalletConnectScreen { approveIcon.assertIsDisplayed() } + } + step("Assert app URL is displayed") { + onWalletConnectScreen { appUrl.assertIsDisplayed() } + } + step("Assert 'New Connection' button is displayed") { + onWalletConnectScreen { newConnectionButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { + step("Assert connection details title is displayed") { + onWalletConnectDetailsBottomSheet { title.assertIsDisplayed() } + } + step("Assert date is displayed") { + onWalletConnectDetailsBottomSheet { date.assertIsDisplayed() } + } + step("Assert 'Close' button is displayed") { + onWalletConnectDetailsBottomSheet { closeButton.assertIsDisplayed() } + } + step("Assert app icon is displayed") { + onWalletConnectDetailsBottomSheet { appIcon.assertIsDisplayed() } + } + step("Assert app name is displayed") { + onWalletConnectDetailsBottomSheet { appName.assertIsDisplayed() } + } + step("Assert approve icon is displayed") { + onWalletConnectDetailsBottomSheet { approveIcon.assertIsDisplayed() } + } + step("Assert app URL is displayed") { + onWalletConnectDetailsBottomSheet { appUrl.assertIsDisplayed() } + } + step("Assert wallet icon is displayed") { + onWalletConnectDetailsBottomSheet { walletIcon.assertIsDisplayed() } + } + step("Assert wallet title is displayed") { + onWalletConnectDetailsBottomSheet { walletTitle.assertIsDisplayed() } + } + step("Assert wallet name is displayed") { + onWalletConnectDetailsBottomSheet { walletName.assertIsDisplayed() } + } + step("Assert 'Connected networks' title is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworksTitle.assertIsDisplayed() } + } + step("Assert connected network item is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworkItem.assertIsDisplayed() } + } + step("Assert connected network icon is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworkIcon.assertIsDisplayed() } + } + step("Assert connected dApp name: '$dAppName'") { + onWalletConnectDetailsBottomSheet { connectedNetworkName.assertTextContains(dAppName) } + } + step("Assert connected network symbol is displayed") { + onWalletConnectDetailsBottomSheet { connectedNetworkSymbol.assertIsDisplayed() } + } + step("Assert 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.assertIsDisplayed() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt new file mode 100644 index 0000000000..ff9f1239bd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -0,0 +1,181 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeUp +import com.tangem.common.utils.getWcUri +import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.screens.* +import com.tangem.steps.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore +import org.junit.Test + +@HiltAndroidTest +class WalletConnectTest : BaseTestCase() { + + @AllureId("3833") + @DisplayName("WC (React App): open session from deeplink on main screen") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSessionOnMainScreen() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Create WC session buy deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Check 'Wallet Connect' bottom sheet") { + checkWalletConnectBottomSheet() + } + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' screen") { + checkWalletConnectScreen() + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Assert connection is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + } + } + + @AllureId("3834") + @DisplayName("WC (React App): open session from deeplink not on main screen") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSessionNotOnMainScreen() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Click on 'Buy' button") { + onMainScreen { buyButton.clickWithAssertion() } + } + step("Create WC session buy deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Check 'Wallet Connect' bottom sheet") { + checkWalletConnectBottomSheet() + } + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } + step("Assert 'Wallet Connect' bottom sheet is displayed") { + onWalletConnectBottomSheet { connectButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' screen") { + checkWalletConnectScreen() + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Assert connection is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + } + } + + @AllureId("886") + @DisplayName("WC (React App): open session from deeplink ") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSession() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Open recent apps") { + device.uiDevice.pressRecentApps() + } + step("Stop app by swipe") { + swipeUp(startHeightRatio = 0.8f) + } + step("Create WC session buy deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Open 'Main Screen'") { + scenario(OpenMainScreenScenario(composeTestRule)) + } + step("Check 'Wallet Connect' bottom sheet") { + checkWalletConnectBottomSheet() + } + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' screen") { + checkWalletConnectScreen() + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect button' is displayed") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Assert connection is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt index 336bec808f..c4f1b6ed48 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -20,6 +21,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags /** * Title component for [TangemModalBottomSheet] with [TangemIconButton] for buttons. @@ -54,7 +56,9 @@ fun TangemModalBottomSheetTitle( text = title.resolveReference(), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(WalletConnectDetailsBottomSheetTestTags.TITLE), ) } if (subtitle != null) { @@ -62,7 +66,9 @@ fun TangemModalBottomSheetTitle( text = subtitle.resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.tertiary, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(WalletConnectDetailsBottomSheetTestTags.DATE), ) } } @@ -72,7 +78,8 @@ fun TangemModalBottomSheetTitle( onClick = onEndClick, modifier = Modifier .padding(16.dp) - .align(Alignment.CenterEnd), + .align(Alignment.CenterEnd) + .testTag(WalletConnectDetailsBottomSheetTestTags.CLOSE_BUTTON), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectBottomSheetTestTags.kt new file mode 100644 index 0000000000..8cdcb5f0a3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectBottomSheetTestTags.kt @@ -0,0 +1,22 @@ +package com.tangem.core.ui.test + +object WalletConnectBottomSheetTestTags { + const val TITLE = "WALLET_CONNECT_BOTTOM_SHEET_TITLE" + const val APP_ICON = "WALLET_CONNECT_BOTTOM_SHEET_APP_ICON" + const val APP_NAME = "WALLET_CONNECT_BOTTOM_SHEET_APP_NAME" + const val APPROVE_ICON = "WALLET_CONNECT_BOTTOM_SHEET_APPROVE_ICON" + const val APP_URL = "WALLET_CONNECT_BOTTOM_SHEET_APP_URL" + + const val CONNECTION_REQUEST_ICON = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_ICON" + const val CONNECTION_REQUEST_TEXT = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_TEXT" + const val CONNECTION_REQUEST_CHEVRON = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_CHEVRON" + + const val WALLET_ICON = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_ICON" + const val WALLET_NAME_TITLE = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_NAME_TITLE" + const val WALLET_NAME = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_NAME" + + const val NETWORKS_ICON = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_ICON" + const val NETWORKS_TITLE = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_TITLE" + const val NETWORKS_SELECTOR_ICON = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_SELECTOR_ICON" + const val NETWORKS_ICONS = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_ICONS" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectDetailsBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectDetailsBottomSheetTestTags.kt new file mode 100644 index 0000000000..f5f7ad7776 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectDetailsBottomSheetTestTags.kt @@ -0,0 +1,16 @@ +package com.tangem.core.ui.test + +object WalletConnectDetailsBottomSheetTestTags { + const val TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_TITLE" + const val DATE = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_DATE" + const val CLOSE_BUTTON = "WALLET_CONNECT_DETAILS_BOTTOM_CLOSE_BUTTON" + const val DISCONNECT_BUTTON = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_DISCONNECT_BUTTON" + const val WALLET_ICON = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_ICON" + const val WALLET_TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET" + const val WALLET_NAME = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_NAME" + const val NETWORKS_TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_NAME" + const val NETWORK_ITEM = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_ITEM" + const val NETWORK_ICON = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_ICON" + const val NETWORK_NAME = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_NAME" + const val NETWORK_SYMBOL = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_SYMBOL" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt new file mode 100644 index 0000000000..8280fc5021 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.test + +object WalletConnectScreenTestTags { + const val MORE_BUTTON = "WALLET_CONNECT_SCREEN_MORE_BUTTON" + const val WALLET_NAME = "WALLET_CONNECT_SCREEN_WALLET_NAME" + const val APP_ICON = "WALLET_CONNECT_SCREEN_APP_ICON" + const val APP_NAME = "WALLET_CONNECT_SCREEN_APP_NAME" + const val APPROVE_ICON = "WALLET_CONNECT_SCREEN_APPROVE_ICON" + const val APP_URL = "WALLET_CONNECT_SCREEN_APP_URL" +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt index 0096e3ef3c..3247db2b9e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -17,6 +18,8 @@ import coil.compose.AsyncImage import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags import com.tangem.features.walletconnect.connections.entity.VerifiedDAppState import com.tangem.features.walletconnect.impl.R @@ -43,7 +46,8 @@ internal fun WcAppInfoItem( AsyncImage( modifier = Modifier .size(TangemTheme.dimens.size48) - .clip(RoundedCornerShape(TangemTheme.dimens.radius8)), + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .testTag(WalletConnectBottomSheetTestTags.APP_ICON), model = iconUrl, contentDescription = title, error = painterResource(R.drawable.img_wc_dapp_icon_placeholder_48), @@ -58,7 +62,9 @@ internal fun WcAppInfoItem( verticalAlignment = Alignment.CenterVertically, ) { Text( - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier + .weight(1f, fill = false) + .testTag(WalletConnectBottomSheetTestTags.APP_NAME), text = title, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.h3, @@ -67,7 +73,9 @@ internal fun WcAppInfoItem( ) if (verifiedDAppState is VerifiedDAppState.Verified) { Icon( - modifier = Modifier.size(20.dp), + modifier = Modifier + .size(20.dp) + .testTag(WalletConnectBottomSheetTestTags.APPROVE_ICON), painter = painterResource(R.drawable.img_approvale2_20), contentDescription = null, tint = Color.Unspecified, @@ -78,6 +86,7 @@ internal fun WcAppInfoItem( text = subtitle, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, + modifier = Modifier.testTag(WalletConnectBottomSheetTestTags.APP_URL), ) } } @@ -86,11 +95,15 @@ internal fun WcAppInfoItem( @Composable internal fun WcNetworkInfoItem(@DrawableRes icon: Int, name: String, symbol: String, modifier: Modifier = Modifier) { Row( - modifier = modifier.padding(vertical = 14.dp, horizontal = 12.dp), + modifier = modifier + .padding(vertical = 14.dp, horizontal = 12.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ITEM), verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_ICON), painter = painterResource(icon), contentDescription = null, tint = Color.Unspecified, @@ -98,7 +111,8 @@ internal fun WcNetworkInfoItem(@DrawableRes icon: Int, name: String, symbol: Str Text( modifier = Modifier .padding(start = 12.dp) - .weight(1f, fill = false), + .weight(1f, fill = false) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_NAME), text = name, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -106,7 +120,9 @@ internal fun WcNetworkInfoItem(@DrawableRes icon: Int, name: String, symbol: Str overflow = TextOverflow.Ellipsis, ) Text( - modifier = Modifier.padding(start = 4.dp), + modifier = Modifier + .padding(start = 4.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORK_SYMBOL), text = symbol, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.tertiary, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt index ebd0adbd3a..0f5487ae09 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoBS.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -42,6 +43,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags import com.tangem.features.walletconnect.connections.entity.* import com.tangem.features.walletconnect.impl.R import kotlinx.collections.immutable.ImmutableList @@ -62,6 +64,7 @@ internal fun WcAppInfoModalBottomSheet(state: WcAppInfoUM, onBack: () -> Unit, o title = resourceReference(R.string.wc_wallet_connect), endIconRes = R.drawable.ic_close_24, onEndClick = onDismiss, + modifier = Modifier.testTag(WalletConnectBottomSheetTestTags.TITLE), ) }, content = { @@ -157,7 +160,9 @@ private fun WcAppInfoFirstBlock(state: WcAppInfoUM.Content, modifier: Modifier = private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_ICON), painter = painterResource(R.drawable.ic_connect_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -165,7 +170,8 @@ private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modif Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing4) - .weight(1f), + .weight(1f) + .testTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_TEXT), text = stringResourceSafe(R.string.wc_connection_request), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -173,7 +179,8 @@ private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modif Icon( modifier = Modifier .padding(start = TangemTheme.dimens.spacing12) - .size(width = 18.dp, height = 24.dp), + .size(width = 18.dp, height = 24.dp) + .testTag(WalletConnectBottomSheetTestTags.CONNECTION_REQUEST_CHEVRON), painter = painterResource(if (expanded) R.drawable.ic_chevron_up_24 else R.drawable.ic_chevron_24), contentDescription = null, tint = TangemTheme.colors.icon.informative, @@ -319,7 +326,9 @@ private fun WcAppInfoSecondBlock(state: WcAppInfoUM.Content, modifier: Modifier private fun WalletRowItem(walletName: String, showEndIcon: Boolean, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectBottomSheetTestTags.WALLET_ICON), painter = painterResource(R.drawable.ic_wallet_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -330,14 +339,18 @@ private fun WalletRowItem(walletName: String, showEndIcon: Boolean, modifier: Mo verticalAlignment = Alignment.CenterVertically, ) { Text( - modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .testTag(WalletConnectBottomSheetTestTags.WALLET_NAME_TITLE), text = stringResourceSafe(R.string.manage_tokens_network_selector_wallet), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, maxLines = 1, ) Text( - modifier = Modifier.padding(start = TangemTheme.dimens.spacing16), + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing16) + .testTag(WalletConnectBottomSheetTestTags.WALLET_NAME), text = walletName, textAlign = TextAlign.End, style = TangemTheme.typography.body1, @@ -366,7 +379,9 @@ private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICON), painter = painterResource(R.drawable.ic_network_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -374,7 +389,8 @@ private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing4) - .weight(1f), + .weight(1f) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_TITLE), text = stringResourceSafe(R.string.wc_common_networks), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -385,7 +401,9 @@ private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier is WcNetworksInfo.NoneNetworksAdded -> Unit } Icon( - modifier = Modifier.size(width = 18.dp, height = 24.dp), + modifier = Modifier + .size(width = 18.dp, height = 24.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON), painter = painterResource(id = R.drawable.ic_select_18_24), contentDescription = null, tint = TangemTheme.colors.icon.informative, @@ -417,7 +435,8 @@ private fun NetworkIcons(items: ImmutableList, modifier: Modi ) .padding(2.dp) .clip(CircleShape) - .size(20.dp), + .size(20.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS), ) } if (remainingCount > 0) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt index 4a97cf03a1..5fab3966ed 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectedAppInfoBS.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -28,6 +29,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat @@ -116,7 +118,8 @@ private fun WcConnectedAppInfoBSContent(state: WcConnectedAppInfoUM, modifier: M SecondaryButton( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), + .padding(vertical = 16.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.DISCONNECT_BUTTON), text = stringResourceSafe(R.string.common_disconnect), enabled = state.disconnectButtonConfig.enabled, showProgress = state.disconnectButtonConfig.showProgress, @@ -142,7 +145,9 @@ private fun AppInfoFirstBlock(state: WcConnectedAppInfoUM, modifier: Modifier = verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.WALLET_ICON), painter = painterResource(R.drawable.ic_wallet_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -150,7 +155,8 @@ private fun AppInfoFirstBlock(state: WcConnectedAppInfoUM, modifier: Modifier = Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing4) - .weight(1f), + .weight(1f) + .testTag(WalletConnectDetailsBottomSheetTestTags.WALLET_TITLE), text = stringResourceSafe(R.string.manage_tokens_network_selector_wallet), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -158,7 +164,8 @@ private fun AppInfoFirstBlock(state: WcConnectedAppInfoUM, modifier: Modifier = Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing16) - .weight(1f), + .weight(1f) + .testTag(WalletConnectDetailsBottomSheetTestTags.WALLET_NAME), text = state.walletName, textAlign = TextAlign.End, style = TangemTheme.typography.body1, @@ -174,7 +181,8 @@ private fun NetworksBlock(networks: ImmutableList, m Text( modifier = Modifier .padding(top = 12.dp, bottom = 4.dp) - .padding(horizontal = 12.dp), + .padding(horizontal = 12.dp) + .testTag(WalletConnectDetailsBottomSheetTestTags.NETWORKS_TITLE), text = stringResourceSafe(R.string.wc_connected_networks), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt index 25acf5033f..f079b379c0 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -35,6 +36,7 @@ import com.tangem.core.ui.components.snackbar.TangemSnackbarHost import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectScreenTestTags import com.tangem.features.walletconnect.connections.entity.* import com.tangem.features.walletconnect.connections.ui.preview.WcConnectionsPreviewData import com.tangem.features.walletconnect.impl.R @@ -170,7 +172,9 @@ private fun ConnectionItem(connection: WcConnectionsUM, modifier: Modifier = Mod Column(modifier = modifier) { key("${connection.userWalletId}_${connection.walletName}") { Text( - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp, start = 12.dp, end = 12.dp), + modifier = Modifier + .padding(top = 12.dp, bottom = 4.dp, start = 12.dp, end = 12.dp) + .testTag(WalletConnectScreenTestTags.WALLET_NAME), text = connection.walletName, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, @@ -199,7 +203,8 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi AsyncImage( modifier = Modifier .size(40.dp) - .clip(RoundedCornerShape(TangemTheme.dimens.radius8)), + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .testTag(WalletConnectScreenTestTags.APP_ICON), model = appInfo.iconUrl, error = painterResource(R.drawable.img_wc_dapp_icon_placeholder_48), fallback = painterResource(R.drawable.img_wc_dapp_icon_placeholder_48), @@ -214,7 +219,9 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi verticalAlignment = Alignment.CenterVertically, ) { Text( - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier + .weight(1f, fill = false) + .testTag(WalletConnectScreenTestTags.APP_NAME), text = appInfo.name, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.subtitle2, @@ -223,7 +230,9 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi ) if (appInfo.verifiedState is VerifiedDAppState.Verified) { Icon( - modifier = Modifier.size(20.dp), + modifier = Modifier + .size(20.dp) + .testTag(WalletConnectScreenTestTags.APPROVE_ICON), painter = painterResource(R.drawable.img_approvale2_20), contentDescription = null, tint = Color.Unspecified, @@ -234,6 +243,7 @@ private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifi text = appInfo.subtitle, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption2, + modifier = Modifier.testTag(WalletConnectScreenTestTags.APP_URL), ) } } @@ -259,6 +269,7 @@ private fun ConnectionsTopBar( TopAppBarButton( button = config.startButtonUM, tint = TangemTheme.colors.icon.primary1, + modifier = Modifier.testTag(WalletConnectScreenTestTags.MORE_BUTTON), ) }, title = { From 33f03e8653ce58ff4a52e267412b52393ea8d7e6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 16:49:41 +0500 Subject: [PATCH 042/132] Updated on 2026-08-14 --- .../tokendetails/TokenDetailsPreviewData.kt | 148 +----------------- .../tokendetails/model/TokenDetailsModel.kt | 52 +----- .../tokendetails/state/TokenDetailsState.kt | 6 +- .../TokenDetailsLoadedBalanceConverter.kt | 17 -- .../TokenDetailsSkeletonStateConverter.kt | 8 - .../state/factory/TokenDetailsStateFactory.kt | 61 -------- .../TokenDetailsLoadedTxHistoryConverter.kt | 48 ------ .../TokenDetailsLoadingTxHistoryConverter.kt | 81 ---------- .../TokenDetailsTxHistoryItemFlowConverter.kt | 103 ------------ ...tailsTxHistoryTransactionStateConverter.kt | 136 ---------------- .../tokendetails/ui/TokenDetailsScreen.kt | 43 +---- 11 files changed, 8 insertions(+), 695 deletions(-) delete mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadedTxHistoryConverter.kt delete mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadingTxHistoryConverter.kt delete mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt delete mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 375e60ec26..01040f4abc 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -1,14 +1,11 @@ package com.tangem.feature.tokendetails.presentation.tokendetails import androidx.compose.ui.graphics.Color -import androidx.paging.PagingData import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -18,7 +15,6 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton import com.tangem.features.tokendetails.impl.R import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal @Suppress("LargeClass") @@ -168,130 +164,6 @@ internal object TokenDetailsPreviewData { onRefresh = {}, ) - private val txHistoryItems = listOf( - TxHistoryState.TxHistoryItemState.Title(onExploreClick = {}), - TxHistoryState.TxHistoryItemState.GroupTitle( - title = "Today", - itemKey = "Today", - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "1", - amount = "-0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.OUTGOING, - onClick = {}, - iconRes = R.drawable.ic_arrow_up_24, - title = stringReference(value = "Sending"), - subtitle = stringReference(value = "to: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "2", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_arrow_down_24, - title = stringReference(value = "Receiving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "3", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_doc_24, - title = stringReference(value = "Approving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "4", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_exchange_vertical_24, - title = stringReference(value = "Swapping"), - subtitle = stringReference(value = "contract: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.GroupTitle( - title = "Yesterday", - itemKey = "Yesterday", - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "5", - amount = "-0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.OUTGOING, - onClick = {}, - iconRes = R.drawable.ic_arrow_up_24, - title = stringReference(value = "Sending"), - subtitle = stringReference(value = "to: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "6", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_arrow_down_24, - title = stringReference(value = "Receiving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "7", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_doc_24, - title = stringReference(value = "Approving"), - subtitle = stringReference(value = "from: 33BddS...ga2B"), - timestamp = 0, - ), - ), - TxHistoryState.TxHistoryItemState.Transaction( - state = TransactionState.Content( - txHash = "8", - amount = "+0.500913 XLM", - time = "8:41", - status = TransactionState.Content.Status.Confirmed, - direction = TransactionState.Content.Direction.INCOMING, - onClick = {}, - iconRes = R.drawable.ic_exchange_vertical_24, - title = stringReference(value = "Swapping"), - subtitle = stringReference(value = "contract: 33BddS...ga2B"), - timestamp = 0, - ), - ), - ) - val tokenDetailsState_1 = TokenDetailsState( topAppBarConfig = tokenDetailsTopAppBarConfig, tokenInfoBlockState = tokenInfoBlockState, @@ -299,13 +171,7 @@ internal object TokenDetailsPreviewData { marketPriceBlockState = marketPriceLoading, stakingBlocksState = stakingLoadingBlock, notifications = persistentListOf(), - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = TxHistoryState.getDefaultLoadingTransactions {}, - ), - ), dialogConfig = null, - pendingTxs = persistentListOf(), expressTxs = persistentListOf(), expressTxsToDisplay = persistentListOf(), pullToRefreshConfig = pullToRefreshConfig, @@ -330,12 +196,7 @@ internal object TokenDetailsPreviewData { ), stakingBlocksState = stakingAvailableBlock, notifications = persistentListOf(), - txHistoryState = TxHistoryState.NotSupported( - onExploreClick = {}, - pendingTransactions = persistentListOf(), - ), dialogConfig = null, - pendingTxs = persistentListOf(), expressTxs = persistentListOf(), expressTxsToDisplay = persistentListOf(), pullToRefreshConfig = pullToRefreshConfig, @@ -344,12 +205,5 @@ internal object TokenDetailsPreviewData { isMarketPriceAvailable = true, ) - val tokenDetailsState_3 = tokenDetailsState_2.copy( - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = PagingData.from(txHistoryItems), - ), - ), - stakingBlocksState = stakingBalanceBlock, - ) + val tokenDetailsState_3 = tokenDetailsState_2.copy(stakingBlocksState = stakingBalanceBlock) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 795dbe542e..cf061237a4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -1,7 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable -import androidx.paging.cachedIn import arrow.core.getOrElse import arrow.core.merge import com.arkivanov.decompose.router.slot.SlotNavigation @@ -24,7 +23,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -69,8 +67,6 @@ import com.tangem.domain.transaction.error.OpenTrustlineError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase -import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -107,8 +103,6 @@ internal class TokenDetailsModel @Inject constructor( private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, - private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, - private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val getExploreUrlUseCase: GetExploreUrlUseCase, private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, private val removeCurrencyUseCase: RemoveCurrencyUseCase, @@ -177,8 +171,6 @@ internal class TokenDetailsModel @Inject constructor( networkHasDerivationUseCase = networkHasDerivationUseCase, getUserWalletUseCase = getUserWalletUseCase, userWalletId = userWalletId, - symbol = cryptoCurrency.symbol, - decimals = cryptoCurrency.decimals, ) private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) { @@ -251,7 +243,6 @@ internal class TokenDetailsModel @Inject constructor( private fun updateContent() { subscribeOnCurrencyStatusUpdates() subscribeOnExpressTransactionsUpdates() - updateTxHistory(refresh = false, showItemsLoading = true, initialUpdating = true) } private fun handleBalanceHiding() { @@ -382,39 +373,8 @@ internal class TokenDetailsModel @Inject constructor( } } - /** - * @param refresh - invalidate cache and get data from remote - * @param showItemsLoading - show loading items placeholder. - */ - private fun updateTxHistory(refresh: Boolean, showItemsLoading: Boolean, initialUpdating: Boolean = false) { - modelScope.launch { - if (!initialUpdating) { - txHistoryContentUpdateEmitter.triggerUpdate() - } else { - val txHistoryItemsCountEither = txHistoryItemsCountUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - ) - - // if countEither is left, handling error state run inside getLoadingTxHistoryState - if (showItemsLoading || txHistoryItemsCountEither.isLeft()) { - internalUiState.value = stateFactory.getLoadingTxHistoryState( - itemsCountEither = txHistoryItemsCountEither, - pendingTransactions = internalUiState.value.pendingTxs, - ) - } - - txHistoryItemsCountEither.onRight { - val maybeTxHistory = txHistoryItemsUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - refresh = refresh, - ).map { it.cachedIn(modelScope) } - - internalUiState.value = stateFactory.getLoadedTxHistoryState(maybeTxHistory) - } - } - } + private fun updateTxHistory() { + modelScope.launch { txHistoryContentUpdateEmitter.triggerUpdate() } } private fun subscribeOnUpdateStakingInfo(cryptoCurrencyStatus: CryptoCurrencyStatus) { @@ -552,8 +512,7 @@ internal class TokenDetailsModel @Inject constructor( override fun onReloadClick() { analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonReload(cryptoCurrency.symbol)) - internalUiState.value = stateFactory.getLoadingTxHistoryState() - updateTxHistory(refresh = true, showItemsLoading = true) + updateTxHistory() } override fun onSendClick(unavailabilityReason: ScenarioUnavailabilityReason) { @@ -800,10 +759,7 @@ internal class TokenDetailsModel @Inject constructor( listOf( async { fetchCurrencyStatusUseCase(userWalletId = userWalletId, id = cryptoCurrency.id) }, async { - updateTxHistory( - refresh = true, - showItemsLoading = internalUiState.value.txHistoryState !is TxHistoryState.Content, - ) + updateTxHistory() subscribeOnExpressTransactionsUpdates() }, ).awaitAll() diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index 594aba1cca..d3a16f09cd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -2,10 +2,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.marketprice.MarketPriceBlockState -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import kotlinx.collections.immutable.ImmutableList @@ -18,10 +16,8 @@ internal data class TokenDetailsState( val marketPriceBlockState: MarketPriceBlockState, val stakingBlocksState: StakingBlockUM?, val notifications: ImmutableList, - val pendingTxs: PersistentList, val expressTxsToDisplay: PersistentList, val expressTxs: PersistentList, - val txHistoryState: TxHistoryState, val dialogConfig: TokenDetailsDialogConfig?, val pullToRefreshConfig: PullToRefreshConfig, val bottomSheetConfig: TangemBottomSheetConfig?, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index 03fbaa9901..86527f1f30 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -5,7 +5,6 @@ import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.format.bigdecimal.* import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource @@ -18,29 +17,20 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.BalanceTy import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsTxHistoryTransactionStateConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getBalance import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.converter.Converter import com.tangem.utils.isNullOrZero import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal -@Suppress("LongParameterList") internal class TokenDetailsLoadedBalanceConverter( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, - private val symbol: String, - private val decimals: Int, private val clickIntents: TokenDetailsClickIntents, ) : Converter, TokenDetailsState> { - private val txHistoryItemConverter by lazy { - TokenDetailsTxHistoryTransactionStateConverter(symbol, decimals, clickIntents) - } - override fun convert(value: Either): TokenDetailsState { return value.fold( ifLeft = { convertError() }, @@ -64,7 +54,6 @@ internal class TokenDetailsLoadedBalanceConverter( private fun convert(status: CryptoCurrencyStatus): TokenDetailsState { val state = currentStateProvider() val currencyName = state.marketPriceBlockState.currencySymbol - val pendingTxs = status.value.pendingTransactions.map(txHistoryItemConverter::convert).toPersistentList() return state.copy( tokenBalanceBlockState = getBalanceState( @@ -73,12 +62,6 @@ internal class TokenDetailsLoadedBalanceConverter( ), stakingBlocksState = state.stakingBlocksState, marketPriceBlockState = getMarketPriceState(status = status.value, currencySymbol = currencyName), - pendingTxs = pendingTxs, - txHistoryState = if (state.txHistoryState is TxHistoryState.NotSupported) { - state.txHistoryState.copy(pendingTransactions = pendingTxs) - } else { - state.txHistoryState - }, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index 3b3acb4d24..ee0b33124a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -4,7 +4,6 @@ import arrow.core.getOrElse import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference @@ -24,7 +23,6 @@ import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow internal class TokenDetailsSkeletonStateConverter( private val clickIntents: TokenDetailsClickIntents, @@ -64,14 +62,8 @@ internal class TokenDetailsSkeletonStateConverter( marketPriceBlockState = MarketPriceBlockState.Loading(value.symbol), stakingBlocksState = StakingBlockUM.Loading(iconState).takeIf { isSupportedInMobileApp }, notifications = persistentListOf(), - pendingTxs = persistentListOf(), expressTxs = persistentListOf(), expressTxsToDisplay = persistentListOf(), - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), - ), - ), dialogConfig = null, pullToRefreshConfig = createPullToRefresh(), bottomSheetConfig = null, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index 22365b4892..cb7d795d31 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -1,14 +1,11 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory -import androidx.paging.PagingData import arrow.core.Either import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme @@ -18,7 +15,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress -import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability @@ -27,8 +23,6 @@ import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning -import com.tangem.domain.txhistory.models.TxHistoryListError -import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents @@ -36,14 +30,9 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBala import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadedTxHistoryConverter -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter.TokenDetailsLoadingTxHistoryModel import com.tangem.features.tokendetails.impl.R import com.tangem.utils.Provider import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow @Suppress("TooManyFunctions", "LargeClass", "LongParameterList") internal class TokenDetailsStateFactory( @@ -54,8 +43,6 @@ internal class TokenDetailsStateFactory( private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val userWalletId: UserWalletId, - symbol: String, - decimals: Int, ) { private val skeletonStateConverter by lazy { @@ -75,8 +62,6 @@ internal class TokenDetailsStateFactory( TokenDetailsLoadedBalanceConverter( currentStateProvider = currentStateProvider, appCurrencyProvider = appCurrencyProvider, - symbol = symbol, - decimals = decimals, clickIntents = clickIntents, ) } @@ -88,22 +73,6 @@ internal class TokenDetailsStateFactory( ) } - private val loadingTransactionsStateConverter by lazy { - TokenDetailsLoadingTxHistoryConverter( - currentStateProvider = currentStateProvider, - clickIntents = clickIntents, - ) - } - - private val loadedTxHistoryConverter by lazy { - TokenDetailsLoadedTxHistoryConverter( - currentStateProvider = currentStateProvider, - clickIntents = clickIntents, - symbol = symbol, - decimals = decimals, - ) - } - private val refreshStateConverter by lazy { TokenDetailsRefreshStateConverter( currentStateProvider = currentStateProvider, @@ -147,36 +116,6 @@ internal class TokenDetailsStateFactory( return tokenDetailsButtonsConverter.convert(actions) } - fun getLoadingTxHistoryState(): TokenDetailsState { - return currentStateProvider().copy( - txHistoryState = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), - ), - ), - ) - } - - fun getLoadingTxHistoryState( - itemsCountEither: Either, - pendingTransactions: List, - ): TokenDetailsState { - return loadingTransactionsStateConverter.convert( - value = TokenDetailsLoadingTxHistoryModel( - historyLoadingState = itemsCountEither, - pendingTransactions = pendingTransactions, - ), - ) - } - - fun getLoadedTxHistoryState( - txHistoryEither: Either>>, - ): TokenDetailsState { - return currentStateProvider().copy( - txHistoryState = loadedTxHistoryConverter.convert(txHistoryEither), - ) - } - fun getStateWithClosedDialog(): TokenDetailsState { val state = currentStateProvider() return state.copy(dialogConfig = state.dialogConfig?.copy(isShow = false)) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadedTxHistoryConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadedTxHistoryConverter.kt deleted file mode 100644 index 7a0e028348..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadedTxHistoryConverter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import androidx.paging.PagingData -import arrow.core.Either -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.txhistory.models.TxHistoryListError -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import kotlinx.coroutines.flow.Flow - -internal class TokenDetailsLoadedTxHistoryConverter( - private val currentStateProvider: Provider, - private val clickIntents: TokenDetailsClickIntents, - symbol: String, - decimals: Int, -) : Converter>>, TxHistoryState> { - - private val txHistoryItemFlowConverter by lazy { - TokenDetailsTxHistoryItemFlowConverter( - currentStateProvider = currentStateProvider, - symbol = symbol, - decimals = decimals, - clickIntents = clickIntents, - ) - } - - override fun convert(value: Either>>): TxHistoryState { - return value.fold(ifLeft = ::convertError, ifRight = ::convert) - } - - private fun convertError(error: TxHistoryListError): TxHistoryState { - return when (error) { - is TxHistoryListError.DataError -> { - TxHistoryState.Error( - onReloadClick = clickIntents::onReloadClick, - onExploreClick = clickIntents::onExploreClick, - ) - } - } - } - - private fun convert(items: Flow>): TxHistoryState { - return txHistoryItemFlowConverter.convert(value = items) - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadingTxHistoryConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadingTxHistoryConverter.kt deleted file mode 100644 index 479a510db6..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsLoadingTxHistoryConverter.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import androidx.paging.PagingData -import arrow.core.Either -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.domain.txhistory.models.TxHistoryStateError -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter.TokenDetailsLoadingTxHistoryModel -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update - -internal class TokenDetailsLoadingTxHistoryConverter( - private val currentStateProvider: Provider, - private val clickIntents: TokenDetailsClickIntents, -) : Converter { - - override fun convert(value: TokenDetailsLoadingTxHistoryModel): TokenDetailsState { - return value.historyLoadingState.fold( - ifLeft = { convertError(error = it, pendingTransactions = value.pendingTransactions) }, - ifRight = ::convert, - ) - } - - private fun convertError( - error: TxHistoryStateError, - pendingTransactions: List, - ): TokenDetailsState { - return currentStateProvider().copy( - txHistoryState = when (error) { - is TxHistoryStateError.EmptyTxHistories -> TxHistoryState.Empty(clickIntents::onExploreClick) - is TxHistoryStateError.DataError -> TxHistoryState.Error( - onReloadClick = clickIntents::onReloadClick, - onExploreClick = clickIntents::onExploreClick, - ) - is TxHistoryStateError.TxHistoryNotImplemented -> { - TxHistoryState.NotSupported( - pendingTransactions = pendingTransactions.toImmutableList(), - onExploreClick = clickIntents::onExploreClick, - ) - } - }, - ) - } - - private fun convert(value: Int): TokenDetailsState { - val state = currentStateProvider() - - return if (state.txHistoryState is TxHistoryState.Content) { - state.txHistoryState.contentItems.update { - PagingData.from(data = createLoadingItems(value)) - } - state - } else { - val txHistoryContent = TxHistoryState.Content( - contentItems = MutableStateFlow( - value = PagingData.from(data = createLoadingItems(value)), - ), - ) - state.copy(txHistoryState = txHistoryContent) - } - } - - private fun createLoadingItems(size: Int): List { - return buildList { - add(TxHistoryState.TxHistoryItemState.Title(onExploreClick = clickIntents::onExploreClick)) - (1..size).forEach { - add(TxHistoryState.TxHistoryItemState.Transaction(state = TransactionState.Loading(it.toString()))) - } - } - } - - data class TokenDetailsLoadingTxHistoryModel( - val historyLoadingState: Either, - val pendingTransactions: List, - ) -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt deleted file mode 100644 index c1cd9f825b..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryItemFlowConverter.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import androidx.paging.* -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.core.ui.components.transactions.state.TxHistoryState.TxHistoryItemState -import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday -import com.tangem.domain.models.network.TxInfo -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import java.util.UUID - -internal class TokenDetailsTxHistoryItemFlowConverter( - private val currentStateProvider: Provider, - private val symbol: String, - private val decimals: Int, - private val clickIntents: TokenDetailsClickIntents, -) : Converter>, TxHistoryState> { - - private val txHistoryItemConverter by lazy { - TokenDetailsTxHistoryTransactionStateConverter( - symbol = symbol, - decimals = decimals, - clickIntents = clickIntents, - ) - } - - override fun convert(value: Flow>): TxHistoryState { - val state = currentStateProvider() - val txHistoryContent = if (state.txHistoryState is TxHistoryState.Content) { - state.txHistoryState - } else { - TxHistoryState.Content(contentItems = MutableStateFlow(PagingData.empty())) - } - // FIXME: TxHistoryRepository should send loading transactions - // [REDACTED_JIRA] - value - .onEach { txHistoryStatePagingData -> - txHistoryContent.contentItems.update { - txHistoryStatePagingData - .map { item -> - // [createTransactionState] returns timestamp without formatting - TxHistoryItemState.Transaction(state = createTransactionState(item)) - } - .insertHeaderItem( - terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE, - item = TxHistoryItemState.Title(clickIntents::onExploreClick), - ) - .insertGroupTitle() - } - } - .launchIn(CoroutineScope(Dispatchers.IO)) - - return txHistoryContent - } - - private fun createTransactionState(item: TxInfo): TransactionState { - return txHistoryItemConverter.convert(value = item) - } - - private fun PagingData.insertGroupTitle(): PagingData { - return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after -> - // Use raw timestamp to get date - - // If [afterDate] is the first transaction in the flow, add the group title - val afterDate = after.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null - if (before is TxHistoryItemState.Title) { - return@insertSeparators TxHistoryItemState.GroupTitle( - title = afterDate, - itemKey = UUID.randomUUID().toString(), - ) - } - - /* - * If [beforeDate] is not equals to [afterDate], then [afterDate] is first transaction in - * the new group - */ - val beforeDate = before.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null - return@insertSeparators if (beforeDate != afterDate) { - TxHistoryItemState.GroupTitle( - title = afterDate, - itemKey = UUID.randomUUID().toString(), - ) - } else { - null - } - } - } - - private fun TxHistoryItemState?.getTimestamp(): Long? { - return if (this is TxHistoryItemState.Transaction && this.state is TransactionState.Content) { - val txContent = this.state as TransactionState.Content - txContent.timestamp - } else { - null - } - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt deleted file mode 100644 index 74c1021d7c..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/txhistory/TokenDetailsTxHistoryTransactionStateConverter.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory - -import com.tangem.common.extensions.isZero -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.components.transactions.state.TransactionState.Content.Direction -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.utils.toTimeFormat -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.models.network.TxInfo.* -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.features.tokendetails.impl.R -import com.tangem.utils.StringsSigns.MINUS -import com.tangem.utils.StringsSigns.PLUS -import com.tangem.utils.converter.Converter -import com.tangem.utils.toBriefAddressFormat - -internal class TokenDetailsTxHistoryTransactionStateConverter( - private val symbol: String, - private val decimals: Int, - private val clickIntents: TokenDetailsClickIntents, -) : Converter { - - override fun convert(value: TxInfo): TransactionState { - return createTransactionStateItem(item = value) - } - - @Suppress("LongMethod") - private fun createTransactionStateItem(item: TxInfo): TransactionState { - return TransactionState.Content( - txHash = item.txHash, - amount = item.getAmount(), - time = item.timestampInMillis.toTimeFormat(), - status = item.status.tiUiStatus(), - direction = item.extractDirection(), - iconRes = item.extractIcon(), - title = item.extractTitle(), - subtitle = item.extractSubtitle(), - timestamp = item.timestampInMillis, - onClick = { clickIntents.onTransactionClick(item.txHash) }, - ) - } - - private fun TxInfo.extractIcon(): Int = if (status == TransactionStatus.Failed) { - R.drawable.ic_close_24 - } else { - when (type) { - is TransactionType.Approve -> R.drawable.ic_doc_24 - is TransactionType.Staking.Stake, - is TransactionType.Staking.Vote, - is TransactionType.Staking.Restake, - -> R.drawable.ic_transaction_history_staking_24 - is TransactionType.Staking.ClaimRewards, - -> R.drawable.ic_transaction_history_claim_rewards_24 - is TransactionType.Staking.Unstake, - is TransactionType.Staking.Withdraw, - -> R.drawable.ic_transaction_history_unstaking_24 - is TransactionType.Operation, - is TransactionType.Swap, - is TransactionType.Transfer, - is TransactionType.UnknownOperation, - -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 - } - } - - private fun TxInfo.extractTitle(): TextReference = when (val type = type) { - is TransactionType.Approve -> resourceReference(R.string.common_approval) - is TransactionType.Operation -> stringReference(type.name) - is TransactionType.Swap -> resourceReference(R.string.common_swap) - is TransactionType.Transfer -> resourceReference(R.string.common_transfer) - is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) - is TransactionType.Staking.Stake -> resourceReference(R.string.common_stake) - is TransactionType.Staking.Unstake -> resourceReference(R.string.common_unstake) - is TransactionType.Staking.Vote -> resourceReference(R.string.staking_vote) - is TransactionType.Staking.ClaimRewards -> resourceReference(R.string.common_claim_rewards) - is TransactionType.Staking.Withdraw -> resourceReference(R.string.staking_withdraw) - is TransactionType.Staking.Restake -> resourceReference(R.string.staking_restake) - } - - private fun TxInfo.extractSubtitle(): TextReference = when (val interactionAddress = interactionAddressType) { - is InteractionAddressType.Contract -> resourceReference( - id = R.string.transaction_history_contract_address, - formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), - ) - is InteractionAddressType.Multiple -> resourceReference( - id = if (isOutgoing) { - R.string.transaction_history_transaction_to_address - } else { - R.string.transaction_history_transaction_from_address - }, - formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)), - ) - is InteractionAddressType.User -> resourceReference( - id = if (isOutgoing) { - R.string.transaction_history_transaction_to_address - } else { - R.string.transaction_history_transaction_from_address - }, - formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), - ) - is InteractionAddressType.Validator -> resourceReference( - id = R.string.transaction_history_transaction_validator, - formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), - ) - null -> { - TextReference.EMPTY - } - } - - private fun TxInfo.extractDirection() = if (isOutgoing) Direction.OUTGOING else Direction.INCOMING - - private fun TransactionStatus.tiUiStatus() = when (this) { - TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed - TransactionStatus.Failed -> TransactionState.Content.Status.Failed - TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed - } - - private fun TxInfo.getAmount(): String { - if (type is TransactionType.Staking.Vote || - type == TransactionType.Staking.ClaimRewards || - type == TransactionType.Staking.Withdraw - ) { - return "" - } - val prefix = when { - status == TransactionStatus.Failed -> "" - this.amount.isZero() -> "" - else -> if (isOutgoing) MINUS else PLUS - } - return prefix + amount.format { crypto(symbol = symbol, decimals = decimals) } - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index a64108bc51..14a17ee2e1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.* import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -14,8 +14,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheet import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet @@ -26,8 +24,6 @@ import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefres import com.tangem.core.ui.components.marketprice.MarketPriceBlock import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.core.ui.components.transactions.txHistoryItems import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsScreenTestTags @@ -46,7 +42,6 @@ import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryUM import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlin.reflect.KProperty // TODO: Split to blocks [REDACTED_JIRA] @Suppress("LongMethod") @@ -63,11 +58,6 @@ internal fun TokenDetailsScreen( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), containerColor = TangemTheme.colors.background.secondary, ) { scaffoldPaddings -> - val txHistoryItems = if (state.txHistoryState is TxHistoryState.Content) { - state.txHistoryState.contentItems.collectAsLazyPagingItems() - } else { - null - } val listState = rememberLazyListState() val txHistoryComponentState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle() val betweenItemsPadding = TangemTheme.dimens.spacing12 @@ -160,14 +150,7 @@ internal fun TokenDetailsScreen( modifier = itemModifier, ) - txHistoryItems( - listState = listState, - txHistoryComponent = txHistoryComponent, - txHistoryComponentState = txHistoryComponentState, - txHistoryState = state.txHistoryState, - txHistoryItems = txHistoryItems, - isBalanceHidden = state.isBalanceHidden, - ) + with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryComponentState) } } } @@ -189,28 +172,6 @@ internal fun TokenDetailsScreen( } } -@Suppress("LongParameterList") -private fun LazyListScope.txHistoryItems( - listState: LazyListState, - txHistoryComponent: TxHistoryComponent, - txHistoryComponentState: TxHistoryUM?, - txHistoryState: TxHistoryState, - txHistoryItems: LazyPagingItems?, - isBalanceHidden: Boolean, -) { - if (txHistoryComponentState != null) { - with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryComponentState) } - } else { - txHistoryItems( - state = txHistoryState, - isBalanceHidden = isBalanceHidden, - txHistoryItems = txHistoryItems, - ) - } -} - -private inline operator fun State?.getValue(thisObj: Any?, property: KProperty<*>): T? = this?.value - // region Preview @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) From e0a8e25779fc17d997879b3954baa91beb680af4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 16:56:18 +0500 Subject: [PATCH 043/132] Updated on 2026-08-14 --- .../tangempay/details/impl/build.gradle.kts | 6 +- .../DefaultTangemPayDetailsComponent.kt | 18 +- .../tangempay/di/TangemPayModelModule.kt | 20 + .../entity/TangemPayDetailsTopBarConfig.kt | 9 + .../tangempay/entity/TangemPayDetailsUM.kt | 35 ++ .../tangempay/model/TangemPayDetailsModel.kt | 61 +++ .../TangemPayDetailsRefreshTransformer.kt | 10 + .../tangempay/ui/TangemPayDetailsScreen.kt | 369 ++++++++++++++++++ 8 files changed, 520 insertions(+), 8 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayDetailsRefreshTransformer.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 4f9fb1c110..ae6c79993c 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -13,9 +13,9 @@ android { dependencies { /** Core */ + implementation(projects.core.configToggles) implementation(projects.core.decompose) implementation(projects.core.ui) - implementation(projects.core.configToggles) /** Features api */ implementation(projects.features.tangempay.details.api) @@ -29,4 +29,8 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.kotlin.immutable.collections) + implementation(deps.timber) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt index 2c55cb6a3a..3fe44cd539 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt @@ -1,26 +1,30 @@ package com.tangem.features.tangempay.components -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.ui.TangemPayDetailsScreen import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("UnusedPrivateMember") internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsComponent.Params, ) : AppComponentContext by appComponentContext, TangemPayDetailsComponent { + private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + @Composable override fun Content(modifier: Modifier) { - Box(modifier.fillMaxSize().background(Color.Red)) - // TODO("[REDACTED_JIRA]") + val state by model.uiState.collectAsStateWithLifecycle() + NavigationBar3ButtonsScrim() + TangemPayDetailsScreen(state = state, modifier = modifier) } @AssistedFactory diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt new file mode 100644 index 0000000000..a9bee0452a --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.tangempay.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.tangempay.model.TangemPayDetailsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface TangemPayModelModule { + + @Binds + @IntoMap + @ClassKey(TangemPayDetailsModel::class) + fun bindWcConnectionsModel(model: TangemPayDetailsModel): Model +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt new file mode 100644 index 0000000000..85908dce5c --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -0,0 +1,9 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayDetailsTopBarConfig( + val onBackClick: () -> Unit, + val items: ImmutableList?, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt new file mode 100644 index 0000000000..dbb7977543 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayDetailsUM( + val topBarConfig: TangemPayDetailsTopBarConfig, + val pullToRefreshConfig: PullToRefreshConfig, + val balanceBlockState: TangemPayDetailsBalanceBlockState, + val cardDetailsUM: TangemPayCardDetailsUM, + val isBalanceHidden: Boolean, +) + +data class TangemPayCardDetailsUM(val number: String, val expiry: String, val cvv: String, val onReveal: () -> Unit) + +internal sealed class TangemPayDetailsBalanceBlockState { + + abstract val actionButtons: ImmutableList + + data class Loading( + override val actionButtons: ImmutableList, + ) : TangemPayDetailsBalanceBlockState() + + data class Content( + override val actionButtons: ImmutableList, + val cryptoBalance: String, + val fiatBalance: String, + val isBalanceFlickering: Boolean, + ) : TangemPayDetailsBalanceBlockState() + + data class Error( + override val actionButtons: ImmutableList, + ) : TangemPayDetailsBalanceBlockState() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt new file mode 100644 index 0000000000..a39519d4a9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -0,0 +1,61 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.transformer.update +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + private val refreshStateJobHolder = JobHolder() + + @Suppress("MagicNumber", "UnusedPrivateMember") + private fun onRefreshSwipe(refreshState: ShowRefreshState) { + uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = true)) + modelScope.launch { + // simulate update logic + delay(2000) + uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) + }.saveIn(refreshStateJobHolder) + } + + private fun getInitialState(): TangemPayDetailsUM { + return TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = router::pop, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = ::onRefreshSwipe), + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()), + cardDetailsUM = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onReveal = {}, + ), + isBalanceHidden = false, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayDetailsRefreshTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayDetailsRefreshTransformer.kt new file mode 100644 index 0000000000..336a2b3a11 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayDetailsRefreshTransformer.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class TangemPayDetailsRefreshTransformer(private val isRefreshing: Boolean) : Transformer { + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + return prevState.copy(pullToRefreshConfig = prevState.pullToRefreshConfig.copy(isRefreshing = isRefreshing)) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt new file mode 100644 index 0000000000..3318cbe1b5 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -0,0 +1,369 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.buttons.HorizontalActionChips +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenDetailsTopBarTestTags +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemPayDetailsScreen(state: TangemPayDetailsUM, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { TangemPayDetailsTopAppBar(config = state.topBarConfig) }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + val listState = rememberLazyListState() + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + TangemPullToRefreshContainer( + config = state.pullToRefreshConfig, + modifier = Modifier.padding(scaffoldPaddings), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues( + bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, + ), + ) { + item( + key = TangemPayDetailsBalanceBlockState::class.java, + content = { + TangemPayDetailsBalanceBlock( + modifier = modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + state = state.balanceBlockState, + isBalanceHidden = state.isBalanceHidden, + ) + }, + ) + item( + key = TangemPayCardDetailsUM::class.java, + content = { + TangemPayCardDetailsBlock( + modifier = modifier + .padding(top = TangemTheme.dimens.spacing12) + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + state = state.cardDetailsUM, + ) + }, + ) + } + } + } +} + +// region Balance block +@Composable +internal fun TangemPayDetailsBalanceBlock( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(vertical = 12.dp), + ) { + Text( + modifier = Modifier.padding(start = 12.dp), + text = "Tangem Pay", + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + FiatBalance( + modifier = Modifier.padding(start = 12.dp, top = 4.dp), + state = state, + isBalanceHidden = isBalanceHidden, + ) + CryptoBalance( + modifier = Modifier.padding(start = 12.dp, top = 4.dp), + state = state, + isBalanceHidden = isBalanceHidden, + ) + if (state.actionButtons.isNotEmpty()) { + HorizontalActionChips( + modifier = Modifier.padding(top = 12.dp), + buttons = state.actionButtons, + containerColor = TangemTheme.colors.background.primary, + contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing12), + ) + } + } +} + +@Composable +private fun FiatBalance( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TangemPayDetailsBalanceBlockState.Loading -> RectangleShimmer( + modifier = modifier.size( + width = TangemTheme.dimens.size102, + height = TangemTheme.dimens.size32, + ), + ) + is TangemPayDetailsBalanceBlockState.Content -> Text( + modifier = modifier, + text = state.fiatBalance.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.h2.applyBladeBrush( + isEnabled = state.isBalanceFlickering, + textColor = TangemTheme.colors.text.primary1, + ), + ) + is TangemPayDetailsBalanceBlockState.Error -> Text( + modifier = modifier, + text = DASH_SIGN.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +private fun CryptoBalance( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TangemPayDetailsBalanceBlockState.Loading -> RectangleShimmer( + modifier = modifier.size( + width = TangemTheme.dimens.size70, + height = TangemTheme.dimens.size16, + ), + ) + is TangemPayDetailsBalanceBlockState.Content -> Text( + modifier = modifier, + text = state.cryptoBalance.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.caption2.applyBladeBrush( + isEnabled = state.isBalanceFlickering, + textColor = TangemTheme.colors.text.tertiary, + ), + ) + is TangemPayDetailsBalanceBlockState.Error -> Text( + modifier = modifier, + text = DASH_SIGN.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} +// endregion + +// region Card details block +@Composable +private fun TangemPayCardDetailsBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(all = 12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Card details", + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + Text( + modifier = Modifier.clickable(onClick = state.onReveal), + text = "Reveal", + color = TangemTheme.colors.text.accent, + style = TangemTheme.typography.body2, + ) + } + CardDetailsTextContainer( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = state.number, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CardDetailsTextContainer(modifier = Modifier.weight(1f), text = state.expiry) + CardDetailsTextContainer(modifier = Modifier.weight(1f), text = state.cvv) + } + } +} + +@Composable +private fun CardDetailsTextContainer(text: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .heightIn(min = 48.dp) + .background(color = TangemTheme.colors.field.primary, shape = RoundedCornerShape(16.dp)), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), + text = text, + maxLines = 1, + color = TangemTheme.colors.text.disabled, + style = TangemTheme.typography.body2, + ) + } +} + +// endregion + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modifier: Modifier = Modifier) { + var showDropdownMenu by rememberSaveable { mutableStateOf(false) } + TopAppBar( + modifier = modifier, + navigationIcon = { + IconButton(onClick = config.onBackClick) { + Icon( + painter = painterResource(id = R.drawable.ic_back_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = "Back", + ) + } + }, + title = {}, + actions = { + AnimatedVisibility(visible = config.items != null && config.items.isNotEmpty()) { + IconButton(onClick = { showDropdownMenu = true }) { + Icon( + painter = painterResource(id = R.drawable.ic_more_vertical_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = "More", + modifier = Modifier.testTag(TokenDetailsTopBarTestTags.MORE_BUTTON), + ) + } + } + + TangemDropdownMenu( + expanded = showDropdownMenu, + modifier = Modifier.background(TangemTheme.colors.background.primary), + onDismissRequest = { showDropdownMenu = false }, + content = { + config.items?.fastForEach { + TangemDropdownItem( + item = it, + dismissParent = { showDropdownMenu = false }, + ) + } + }, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = TangemTheme.colors.background.secondary, + titleContentColor = TangemTheme.colors.icon.primary1, + actionIconContentColor = TangemTheme.colors.icon.primary1, + ), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayDetailsScreenPreview( + @PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM, +) { + TangemThemePreview { + TangemPayDetailsScreen(state = state) + } +} + +private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + balanceBlockState = TangemPayDetailsBalanceBlockState.Content( + actionButtons = persistentListOf( + ActionButtonConfig( + text = resourceReference(id = R.string.common_receive), + iconResId = R.drawable.ic_arrow_down_24, + onClick = {}, + ), + ), + cryptoBalance = "1234.56 USDT", + fiatBalance = "$1234.56", + isBalanceFlickering = false, + ), + cardDetailsUM = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onReveal = {}, + ), + isBalanceHidden = false, + ), + TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()), + cardDetailsUM = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onReveal = {}, + ), + isBalanceHidden = false, + ), + ), +) \ No newline at end of file From aa9eda075833f06bd30fb2bdfc7be690c91d3363 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 21:47:00 +0400 Subject: [PATCH 044/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- libs/crypto/build.gradle.kts | 31 +++-- .../derivation/AccountNodeRecognizer.kt | 46 +++++++ .../derivation/MutableDerivationPath.kt | 44 +++++++ .../derivation/AccountNodeRecognizerTest.kt | 116 ++++++++++++++++++ .../derivation/MutableDerivationPathTest.kt | 92 ++++++++++++++ 6 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt create mode 100644 libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt create mode 100644 libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt create mode 100644 libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/MutableDerivationPathTest.kt diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index a9c2010b63..75a7c7f036 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -7,7 +7,7 @@ tangemBlockchainSdk = "develop-1213" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-560" +tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/crypto/build.gradle.kts b/libs/crypto/build.gradle.kts index 0278a0501c..2adf84b11c 100644 --- a/libs/crypto/build.gradle.kts +++ b/libs/crypto/build.gradle.kts @@ -10,17 +10,30 @@ android { namespace = "com.tangem.lib.crypto" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { - /** Coroutines */ - implementation(deps.kotlin.coroutines) - - /** SDK */ - implementation(tangemDeps.blockchain) - - /** Core */ + // region Project implementation(projects.core.utils) - - /** Libs */ implementation(projects.libs.blockchainSdk) + // endregion + + // region Tangem SDKs + implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) + // endregion + + // region Other deps + implementation(deps.kotlin.coroutines) + implementation(deps.timber) + // endregion + + // region Test libraries + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.truth) + // endregion } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt new file mode 100644 index 0000000000..6008e6ea58 --- /dev/null +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -0,0 +1,46 @@ +package com.tangem.lib.crypto.derivation + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.isUTXO +import com.tangem.crypto.hdWallet.DerivationPath + +/** + * Utility class to recognize the account node in a derivation path based on the blockchain type. + * Derivation path schema: [ m / purpose' / coin_type' / account' / change / address_index ]. + * + * @param blockchain the blockchain for which the account node is to be recognized + * +[REDACTED_AUTHOR] + */ +class AccountNodeRecognizer(blockchain: Blockchain) { + + /** Index of the account node in the derivation path */ + val accountNodeIndex: Int = if (blockchain.isUTXO) { + UTXO_BLOCKCHAIN_NODE_INDEX + } else { + NON_UTXO_BLOCKCHAIN_NODE_INDEX + } + + /** Recognizes the account node value from the given derivation path string [derivationPathValue] */ + fun recognize(derivationPathValue: String): Long? { + return runCatching { + recognize(derivationPath = DerivationPath(rawPath = derivationPathValue)) + } + .getOrNull() + } + + /** Recognizes the account node value from the given [derivationPath] */ + fun recognize(derivationPath: DerivationPath): Long? { + return runCatching { + val accountNode = derivationPath.nodes.getOrNull(accountNodeIndex) + + accountNode?.getIndex(includeHardened = false) + } + .getOrNull() + } + + private companion object { + const val UTXO_BLOCKCHAIN_NODE_INDEX = 2 + const val NON_UTXO_BLOCKCHAIN_NODE_INDEX = 4 + } +} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt new file mode 100644 index 0000000000..92ec3a5e41 --- /dev/null +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt @@ -0,0 +1,44 @@ +package com.tangem.lib.crypto.derivation + +import com.tangem.blockchain.common.Blockchain +import com.tangem.crypto.hdWallet.DerivationNode +import com.tangem.crypto.hdWallet.DerivationPath +import timber.log.Timber + +/** Extension function to convert a [DerivationPath] into a [MutableDerivationPath] */ +fun DerivationPath.toMutable(): MutableDerivationPath = MutableDerivationPath(value = this) + +/** + * A mutable representation of a derivation path, allowing modifications to specific nodes + * + * @property value the initial derivationPath to be used + */ +class MutableDerivationPath internal constructor(val value: DerivationPath) { + + /** + * Replaces the account node in the derivation path with a new value + * + * @param value the new index to set for the account node + * @param blockchain the blockchain used to determine the account node index + */ + fun replaceAccountNode(value: Long, blockchain: Blockchain): MutableDerivationPath { + val mutableNodes = this@MutableDerivationPath.value.nodes.toMutableList() + + val accountNodeIndex = AccountNodeRecognizer(blockchain).accountNodeIndex + val accountNode = mutableNodes.getOrNull(accountNodeIndex) + + if (accountNode != null) { + mutableNodes[accountNodeIndex] = when (accountNode) { + is DerivationNode.Hardened -> DerivationNode.Hardened(value) + is DerivationNode.NonHardened -> DerivationNode.NonHardened(value) + } + } else { + Timber.e("Account node not found in the derivation path: ${this@MutableDerivationPath.value}") + } + + return DerivationPath(path = mutableNodes).toMutable() + } + + /** Applies the changes and returns it as an immutable [DerivationPath] */ + fun apply(): DerivationPath = value +} \ No newline at end of file diff --git a/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt new file mode 100644 index 0000000000..4295568a38 --- /dev/null +++ b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt @@ -0,0 +1,116 @@ +package com.tangem.lib.crypto.derivation + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.crypto.hdWallet.DerivationPath +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class AccountNodeRecognizerTest { + + private val utxoBlockchain = Blockchain.Bitcoin + private val nonUtxoBlockchain = Blockchain.Ethereum + + @Nested + inner class RecognizeAsDerivationPath { + + @Test + fun `returns account node value for UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/1'/0/0") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 1 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0'/0/0") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 0 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns null if derivation path is shorter than expected`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + } + + @Nested + inner class RecognizeAsString { + + @Test + fun `returns account node value for UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPath = "m/44'/0'/1'/0/0" + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 1 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = "m/44'/0'/0'/0/0" + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 0 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns null if derivation path is shorter than expected`() { + // Arrange + val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val derivationPath = "m/44'/0'" + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns null if derivation path string is invalid`() { + // Arrange + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPathValue = "invalid/path" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + Truth.assertThat(actual).isNull() + } + } +} \ No newline at end of file diff --git a/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/MutableDerivationPathTest.kt b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/MutableDerivationPathTest.kt new file mode 100644 index 0000000000..3a364785be --- /dev/null +++ b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/MutableDerivationPathTest.kt @@ -0,0 +1,92 @@ +package com.tangem.lib.crypto.derivation + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.crypto.hdWallet.DerivationPath +import org.junit.jupiter.api.Test + +internal class MutableDerivationPathTest { + + private val utxoBlockchain = Blockchain.Bitcoin + private val nonUtxoBlockchain = Blockchain.Ethereum + + @Test + fun `replaces account node with hardened value`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0'/0/0") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 1, blockchain = utxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/0'/1'/0/0") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `replaces account node with non hardened value`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0/0/0") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 1, blockchain = utxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/0'/1/0/0") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `does nothing if account node not found`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/0'") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 1, blockchain = utxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/0'") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `replaces account node with hardened value for non-utxo blockchain`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/60'/0'/0/0") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 2, blockchain = nonUtxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/60'/0'/0/2") + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `does nothing if account node not found for non-utxo blockchain`() { + // Arrange + val derivationPath = DerivationPath(rawPath = "m/44'/60'") + val mutablePath = derivationPath.toMutable() + + // Act + val actual = mutablePath + .replaceAccountNode(value = 2, blockchain = nonUtxoBlockchain) + .apply() + + // Assert + val expected = DerivationPath(rawPath = "m/44'/60'") + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file From f4e72d4e9f3c343603342df4f758804ca0259c67 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Sep 2025 14:45:36 +0300 Subject: [PATCH 045/132] Updated on 2026-08-14 --- fastlane/Fastfile | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 09eae1b14d..efbc818ece 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -32,40 +32,6 @@ platform :android do gradle(task: "testDebugUnitTest") end - - desc "Build release AAB and APK" - lane :buildRelease do |options| - FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/release/google-services.json", "../app") - FileUtils.cp("../tangem-android-tools/CI/gradle_properties/build_ci_gradle.properties", "../gradle.properties") - puts File.read("../gradle.properties") - - gradle( - task: "bundle", - build_type: "Release", - properties: { - 'versionCode' => options[:versionCode], - 'versionName' => options[:versionName], - "android.injected.signing.store.file" => options[:keystore], - "android.injected.signing.store.password" => options[:store_password], - "android.injected.signing.key.alias" => options[:key_alias], - "android.injected.signing.key.password" => options[:key_password], - } - ) - gradle( - task: "assemble", - build_type: "Release", - properties: { - 'versionCode' => options[:versionCode], - 'versionName' => options[:versionName], - "android.injected.signing.store.file" => options[:keystore], - "android.injected.signing.store.password" => options[:store_password], - "android.injected.signing.key.alias" => options[:key_alias], - "android.injected.signing.key.password" => options[:key_password], - } - ) - end - - desc "Build internal APK Firebase App Distribution" lane :buildInternal do |options| FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/dev/google-services.json", "../app") From 38614dbf4f4483be0a91c8ce48acfeb9003f2a01 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Sep 2025 18:42:40 +0500 Subject: [PATCH 046/132] Updated on 2026-08-14 --- .../entry/AddExistingWalletModel.kt | 27 ++++++++++++++++++- .../entry/WalletActivationModel.kt | 27 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt index 328e74cc8c..c562032c4a 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt @@ -2,9 +2,15 @@ package com.tangem.features.hotwallet.addexistingwallet.entry import com.arkivanov.decompose.router.stack.* import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model 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.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute @@ -26,6 +32,7 @@ internal class AddExistingWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback() @@ -69,13 +76,31 @@ internal class AddExistingWalletModel @Inject constructor( stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished) } + private fun showSkipAccessCodeWarningDialog() { + uiMessageSender.send( + DialogMessage( + message = resourceReference(R.string.access_code_alert_skip_description), + title = resourceReference(R.string.access_code_alert_skip_title), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_cancel), + onClick = {}, + ), + secondAction = EventMessageAction( + title = resourceReference(R.string.access_code_alert_skip_ok), + onClick = { navigateToPushNotificationsOrNext() }, + ), + dismissOnFirstAction = true, + ), + ) + } + inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback { override fun onBackClick() { onChildBack() } override fun onSkipClick() { - navigateToPushNotificationsOrNext() + showSkipAccessCodeWarningDialog() } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt index f163ba6e18..722d2a7480 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt @@ -4,10 +4,16 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.decompose.di.GlobalUiMessageSender 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.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent @@ -32,6 +38,7 @@ internal class WalletActivationModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { val params = paramsContainer.require() @@ -79,13 +86,31 @@ internal class WalletActivationModel @Inject constructor( stackNavigation.replaceAll(WalletActivationRoute.SetupFinished) } + private fun showSkipAccessCodeWarningDialog() { + uiMessageSender.send( + DialogMessage( + message = resourceReference(R.string.access_code_alert_skip_description), + title = resourceReference(R.string.access_code_alert_skip_title), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_cancel), + onClick = {}, + ), + secondAction = EventMessageAction( + title = resourceReference(R.string.access_code_alert_skip_ok), + onClick = { navigateToPushNotificationsOrNext() }, + ), + dismissOnFirstAction = true, + ), + ) + } + inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback { override fun onBackClick() { onChildBack() } override fun onSkipClick() { - navigateToPushNotificationsOrNext() + showSkipAccessCodeWarningDialog() } } From ef59ea0e18da5bf23beb3cf86b1e788d9d0f9a69 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Sep 2025 10:34:41 +0500 Subject: [PATCH 047/132] Updated on 2026-08-14 --- app/src/main/assets/testnet_tokens.json | 568 ++++++++++++------------ gradle/tangem_dependencies.toml | 2 +- 2 files changed, 297 insertions(+), 273 deletions(-) diff --git a/app/src/main/assets/testnet_tokens.json b/app/src/main/assets/testnet_tokens.json index ad10260b6b..ebb0fee019 100644 --- a/app/src/main/assets/testnet_tokens.json +++ b/app/src/main/assets/testnet_tokens.json @@ -1,160 +1,165 @@ { - "coins" : [ + "coins": [ { - "id" : "matic-token", - "symbol" : "MATIC", - "name" : "Polygon", - "networks" : [ + "id": "matic-token", + "symbol": "MATIC", + "name": "Polygon", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x0000000000000000000000000000000000001010", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x0000000000000000000000000000000000001010", + "decimalCount": 18 } ] }, { - "id" : "kaspa", - "symbol" : "KAS", - "name" : "Kaspa", - "networks" : [ + "id": "kaspa", + "symbol": "KAS", + "name": "Kaspa", + "networks": [ { - "networkId" : "kaspa/test" + "networkId": "kaspa/test" } ] }, { - "id" : "Dai Stablecoin-DAI", - "symbol" : "DAI", - "name" : "Dai Stablecoin", - "networks" : [ + "id": "Dai Stablecoin-DAI", + "symbol": "DAI", + "name": "Dai Stablecoin", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xcB1e72786A6eb3b44C2a2429e317c8a2462CFeb1", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xcB1e72786A6eb3b44C2a2429e317c8a2462CFeb1", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xec5dcb5dbf4b114c9d0f65bccab49ec54f6a0867", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xec5dcb5dbf4b114c9d0f65bccab49ec54f6a0867", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x8a9424745056eb399fd19a0ec26a14316684e274", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x8a9424745056eb399fd19a0ec26a14316684e274", + "decimalCount": 18 } ] }, { - "id" : "Dummy ERC20-DERC20", - "symbol" : "DERC20", - "name" : "Dummy ERC20", - "networks" : [ + "id": "Dummy ERC20-DERC20", + "symbol": "DERC20", + "name": "Dummy ERC20", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xfe4F5145f6e09952a5ba9e956ED0C25e3Fa4c7F1", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xfe4F5145f6e09952a5ba9e956ED0C25e3Fa4c7F1", + "decimalCount": 18 } ] }, { - "id" : "Ether-ETH", - "symbol" : "ETH", - "name" : "Ether", - "networks" : [ + "id": "Ether-ETH", + "symbol": "ETH", + "name": "Ether", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x714550C2C1Ea08688607D86ed8EeF4f5E4F22323", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x714550C2C1Ea08688607D86ed8EeF4f5E4F22323", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xd66c6b4f0be8ce5b39d52e0fd1344c389929b378", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xd66c6b4f0be8ce5b39d52e0fd1344c389929b378", + "decimalCount": 18 } ] }, { - "id" : "Test Token-TST", - "symbol" : "TST", - "name" : "Test Token", - "networks" : [ + "id": "Test Token-TST", + "symbol": "TST", + "name": "Test Token", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x2d7882bedcbfddce29ba99965dd3cdf7fcb10a1e", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x2d7882bedcbfddce29ba99965dd3cdf7fcb10a1e", + "decimalCount": 18 } ] }, { - "id" : "tether", - "symbol" : "USDT", - "name" : "Tether USD", - "networks" : [ + "id": "tether", + "symbol": "USDT", + "name": "Tether USD", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0x3813e82e6f7098b9583FC0F33a962D02018B6803", - "decimalCount" : 6 + "networkId": "ethereum/test", + "contractAddress": "0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x337610d27c682e347c9cd60bd4b3b107c9d34ddd", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0x3813e82e6f7098b9583FC0F33a962D02018B6803", + "decimalCount": 6 }, { - "networkId" : "tron/test", - "contractAddress" : "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", - "decimalCount" : 6 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x337610d27c682e347c9cd60bd4b3b107c9d34ddd", + "decimalCount": 18 }, { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x7ef95a0fee0dd31b22626fa2e10ee6a223f8a684", - "decimalCount" : 18 + "networkId": "tron/test", + "contractAddress": "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", + "decimalCount": 6 + }, + { + "networkId": "binance-smart-chain/test", + "contractAddress": "0x7ef95a0fee0dd31b22626fa2e10ee6a223f8a684", + "decimalCount": 18 } ] }, { - "id" : "just-gov", - "symbol" : "JST", - "name" : "JUST GOV", - "networks" : [ + "id": "just-gov", + "symbol": "JST", + "name": "JUST GOV", + "networks": [ { - "networkId" : "tron/test", - "contractAddress" : "TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3", - "decimalCount" : 18 + "networkId": "tron/test", + "contractAddress": "TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3", + "decimalCount": 18 } ] }, { - "id" : "Wrapped Ether-WETH", - "symbol" : "WETH", - "name" : "Wrapped Ether", - "networks" : [ + "id": "Wrapped Ether-WETH", + "symbol": "WETH", + "name": "Wrapped Ether", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xA6FA4fB5f76172d178d61B04b0ecd319C5d1C0aa", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xA6FA4fB5f76172d178d61B04b0ecd319C5d1C0aa", + "decimalCount": 18 } ] }, { - "id" : "Wrapped Matic-WMATIC", - "symbol" : "WMATIC", - "name" : "Wrapped Matic", - "networks" : [ + "id": "Wrapped Matic-WMATIC", + "symbol": "WMATIC", + "name": "Wrapped Matic", + "networks": [ { - "networkId" : "polygon-pos/test", - "contractAddress" : "0xd0A1E359811322d97991E03f863a0C30C2cF029C", - "decimalCount" : 18 + "networkId": "polygon-pos/test", + "contractAddress": "0xd0A1E359811322d97991E03f863a0C30C2cF029C", + "decimalCount": 18 } ] }, { - "id" : "solana", - "symbol" : "SOL", - "name" : "Solana", - "networks" : [ + "id": "solana", + "symbol": "SOL", + "name": "Solana", + "networks": [ { - "networkId" : "solana/test" + "networkId": "solana/test" } ] }, @@ -162,263 +167,262 @@ "id": "the-open-network", "symbol": "TON", "name": "Toncoin", - "networks": - [ + "networks": [ { "networkId": "the-open-network/test" } ] }, { - "id" : "Tangem Coin A-TCA", - "symbol" : "TCA", - "name" : "Tangem Coin A", - "networks" : [ + "id": "Tangem Coin A-TCA", + "symbol": "TCA", + "name": "Tangem Coin A", + "networks": [ { - "networkId" : "solana/test", - "contractAddress" : "22PTNbX31Zuztd6nD82fC8nQdT2hUfWv9XWXKuDkrFqR", - "decimalCount" : 9 + "networkId": "solana/test", + "contractAddress": "22PTNbX31Zuztd6nD82fC8nQdT2hUfWv9XWXKuDkrFqR", + "decimalCount": 9 } ] }, { - "id" : "Tangem Coin B-TCB", - "symbol" : "TCB", - "name" : "Tangem Coin B", - "networks" : [ + "id": "Tangem Coin B-TCB", + "symbol": "TCB", + "name": "Tangem Coin B", + "networks": [ { - "networkId" : "solana/test", - "contractAddress" : "HmSghNPg6KCk711YJA92aPejt8auyFkvmaED6jbHfUs4", - "decimalCount" : 9 + "networkId": "solana/test", + "contractAddress": "HmSghNPg6KCk711YJA92aPejt8auyFkvmaED6jbHfUs4", + "decimalCount": 9 } ] }, { - "id" : "binancecoin", - "symbol" : "BNB", - "name" : "Binance", - "networks" : [ + "id": "binancecoin", + "symbol": "BNB", + "name": "Binance", + "networks": [ { - "networkId" : "binancecoin/test" + "networkId": "binancecoin/test" }, { - "networkId" : "binance-smart-chain/test" + "networkId": "binance-smart-chain/test" } ] }, { - "id" : "Hemster - 452-HEM", - "symbol" : "HEM", - "name" : "Hemster - 452", - "networks" : [ + "id": "Hemster - 452-HEM", + "symbol": "HEM", + "name": "Hemster - 452", + "networks": [ { - "networkId" : "binancecoin/test", - "contractAddress" : "HEM-452", - "decimalCount" : 8 + "networkId": "binancecoin/test", + "contractAddress": "HEM-452", + "decimalCount": 8 } ] }, { - "id" : "Binance-Peg BTCB Token-BTCB", - "symbol" : "BTCB", - "name" : "Binance-Peg BTCB Token", - "networks" : [ + "id": "Binance-Peg BTCB Token-BTCB", + "symbol": "BTCB", + "name": "Binance-Peg BTCB Token", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x6ce8da28e2f864420840cf74474eff5fd80e65b8", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x6ce8da28e2f864420840cf74474eff5fd80e65b8", + "decimalCount": 18 } ] }, { - "id" : "Binance-Peg BUSD Token-BUSD", - "symbol" : "BUSD", - "name" : "Binance-Peg BUSD Token", - "networks" : [ + "id": "Binance-Peg BUSD Token-BUSD", + "symbol": "BUSD", + "name": "Binance-Peg BUSD Token", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xed24fc36d5ee211ea25a80239fb8c4cfd80f12ee", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xed24fc36d5ee211ea25a80239fb8c4cfd80f12ee", + "decimalCount": 18 } ] }, { - "id" : "Binance-Peg USDC Token-USDC", - "symbol" : "USDC", - "name" : "Binance-Peg USDC Token", - "networks" : [ + "id": "Binance-Peg USDC Token-USDC", + "symbol": "USDC", + "name": "Binance-Peg USDC Token", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0x64544969ed7ebf5f083679233325356ebe738930", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0x64544969ed7ebf5f083679233325356ebe738930", + "decimalCount": 18 } ] }, { - "id" : "Binance-Peg XRP-XRP", - "symbol" : "XRP", - "name" : "Binance-Peg XRP", - "networks" : [ + "id": "Binance-Peg XRP-XRP", + "symbol": "XRP", + "name": "Binance-Peg XRP", + "networks": [ { - "networkId" : "binance-smart-chain/test", - "contractAddress" : "0xa83575490d7df4e2f47b7d38ef351a2722ca45b9", - "decimalCount" : 18 + "networkId": "binance-smart-chain/test", + "contractAddress": "0xa83575490d7df4e2f47b7d38ef351a2722ca45b9", + "decimalCount": 18 } ] }, { - "id" : "ethereum", - "symbol" : "ETH", - "name" : "Ethereum", - "networks" : [ + "id": "ethereum", + "symbol": "ETH", + "name": "Ethereum", + "networks": [ { - "networkId" : "ethereum/test" + "networkId": "ethereum/test" } ] }, { - "id" : "ethereum-classic", - "symbol" : "ETC", - "name" : "Ethereum Classic", - "networks" : [ + "id": "ethereum-classic", + "symbol": "ETC", + "name": "Ethereum Classic", + "networks": [ { - "networkId" : "ethereum-classic/test" + "networkId": "ethereum-classic/test" } ] }, { - "id" : "Weenus-WEENUS", - "symbol" : "WEENUS", - "name" : "Weenus", - "networks" : [ + "id": "Weenus-WEENUS", + "symbol": "WEENUS", + "name": "Weenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0xaFF4481D10270F50f203E0763e2597776068CBc5", - "decimalCount" : 18 + "networkId": "ethereum/test", + "contractAddress": "0xaFF4481D10270F50f203E0763e2597776068CBc5", + "decimalCount": 18 } ] }, { - "id" : "Xeenus-XEENUS", - "symbol" : "XEENUS", - "name" : "Xeenus", - "networks" : [ + "id": "Xeenus-XEENUS", + "symbol": "XEENUS", + "name": "Xeenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0x022E292b44B5a146F2e8ee36Ff44D3dd863C915c", - "decimalCount" : 18 + "networkId": "ethereum/test", + "contractAddress": "0x022E292b44B5a146F2e8ee36Ff44D3dd863C915c", + "decimalCount": 18 } ] }, { - "id" : "Yeenus-YEENUS", - "symbol" : "YEENUS", - "name" : "Yeenus", - "networks" : [ + "id": "Yeenus-YEENUS", + "symbol": "YEENUS", + "name": "Yeenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0xc6fDe3FD2Cc2b173aEC24cc3f267cb3Cd78a26B7", - "decimalCount" : 8 + "networkId": "ethereum/test", + "contractAddress": "0xc6fDe3FD2Cc2b173aEC24cc3f267cb3Cd78a26B7", + "decimalCount": 8 } ] }, { - "id" : "Zeenus-ZEENUS", - "symbol" : "ZEENUS", - "name" : "Zeenus", - "networks" : [ + "id": "Zeenus-ZEENUS", + "symbol": "ZEENUS", + "name": "Zeenus", + "networks": [ { - "networkId" : "ethereum/test", - "contractAddress" : "0x1f9061B953bBa0E36BF50F21876132DcF276fC6e", - "decimalCount" : 0 + "networkId": "ethereum/test", + "contractAddress": "0x1f9061B953bBa0E36BF50F21876132DcF276fC6e", + "decimalCount": 0 } ] }, { - "id" : "avalanche-2", - "symbol" : "AVAX", - "name" : "Avalanche", - "networks" : [ + "id": "avalanche-2", + "symbol": "AVAX", + "name": "Avalanche", + "networks": [ { - "networkId" : "avalanche/test" + "networkId": "avalanche/test" } ] }, { - "id" : "The Fuji stablecoin-FUJISTABLE", - "symbol" : "FUJISTABLE", - "name" : "The Fuji stablecoin", - "networks" : [ + "id": "The Fuji stablecoin-FUJISTABLE", + "symbol": "FUJISTABLE", + "name": "The Fuji stablecoin", + "networks": [ { - "networkId" : "avalanche/test", - "contractAddress" : "0x2058ec2791dD28b6f67DB836ddf87534F4Bbdf22", - "decimalCount" : 6 + "networkId": "avalanche/test", + "contractAddress": "0x2058ec2791dD28b6f67DB836ddf87534F4Bbdf22", + "decimalCount": 6 } ] }, { - "id" : "To the Moon-FUJIMOON", - "symbol" : "FUJIMOON", - "name" : "To the Moon", - "networks" : [ + "id": "To the Moon-FUJIMOON", + "symbol": "FUJIMOON", + "name": "To the Moon", + "networks": [ { - "networkId" : "avalanche/test", - "contractAddress" : "0x97132C109c6816525F7f338DCb7435E1412A7668", - "decimalCount" : 9 + "networkId": "avalanche/test", + "contractAddress": "0x97132C109c6816525F7f338DCb7435E1412A7668", + "decimalCount": 9 } ] }, { - "id" : "fantom", - "symbol" : "FTM", - "name" : "Fantom", - "networks" : [ + "id": "fantom", + "symbol": "FTM", + "name": "Fantom", + "networks": [ { - "networkId" : "fantom/test" + "networkId": "fantom/test" } ] }, { - "id" : "Fantom USD-FUSD", - "symbol" : "FUSD", - "name" : "Fantom USD", - "networks" : [ + "id": "Fantom USD-FUSD", + "symbol": "FUSD", + "name": "Fantom USD", + "networks": [ { - "networkId" : "fantom/test", - "contractAddress" : "0x91ea991bd52EE3C40EdA2509701d905e1Ee54074", - "decimalCount" : 18 + "networkId": "fantom/test", + "contractAddress": "0x91ea991bd52EE3C40EdA2509701d905e1Ee54074", + "decimalCount": 18 } ] }, { - "id" : "Wrapped Fantom-WFTM", - "symbol" : "WFTM", - "name" : "Wrapped Fantom", - "networks" : [ + "id": "Wrapped Fantom-WFTM", + "symbol": "WFTM", + "name": "Wrapped Fantom", + "networks": [ { - "networkId" : "fantom/test", - "contractAddress" : "0xf1277d1Ed8AD466beddF92ef448A132661956621", - "decimalCount" : 18 + "networkId": "fantom/test", + "contractAddress": "0xf1277d1Ed8AD466beddF92ef448A132661956621", + "decimalCount": 18 } ] }, { - "id" : "bitcoin", - "symbol" : "BTC", - "name" : "Bitcoin", - "networks" : [ + "id": "bitcoin", + "symbol": "BTC", + "name": "Bitcoin", + "networks": [ { - "networkId" : "bitcoin/test" + "networkId": "bitcoin/test" } ] }, { - "id" : "vechain", - "symbol" : "VET", - "name" : "VeChain", - "networks" : [ + "id": "vechain", + "symbol": "VET", + "name": "VeChain", + "networks": [ { - "networkId" : "vechain/test" + "networkId": "vechain/test" } ] }, @@ -435,34 +439,34 @@ ] }, { - "id" : "tron", - "symbol" : "TRX", - "name" : "Tron", - "networks" : [ - { - "networkId" : "tron/test" - } - ] + "id": "tron", + "symbol": "TRX", + "name": "Tron", + "networks": [ + { + "networkId": "tron/test" + } + ] }, { - "id" : "algorand", - "symbol" : "ALGO", - "name" : "Algorand", - "networks" : [ - { - "networkId" : "algorand/test" - } - ] + "id": "algorand", + "symbol": "ALGO", + "name": "Algorand", + "networks": [ + { + "networkId": "algorand/test" + } + ] }, { - "id" : "arbitrum-one", - "symbol" : "ETH", - "name" : "Arbitrum", - "networks" : [ - { - "networkId" : "arbitrum-one/test" - } - ] + "id": "arbitrum-one", + "symbol": "ETH", + "name": "Arbitrum", + "networks": [ + { + "networkId": "arbitrum-one/test" + } + ] }, { "id": "stellar", @@ -485,12 +489,12 @@ ] }, { - "id" : "polkadot", - "symbol" : "DOT", - "name" : "Polkadot", - "networks" : [ + "id": "polkadot", + "symbol": "DOT", + "name": "Polkadot", + "networks": [ { - "networkId" : "polkadot/test" + "networkId": "polkadot/test" } ] }, @@ -509,8 +513,7 @@ "id": "kava", "symbol": "KAVA", "name": "Kava EVM", - "networks": - [ + "networks": [ { "networkId": "kava/test" } @@ -520,8 +523,7 @@ "id": "telos", "symbol": "TLOS", "name": "Telos EVM", - "networks": - [ + "networks": [ { "networkId": "telos/test" } @@ -531,8 +533,7 @@ "id": "ravencoin", "symbol": "RVN", "name": "Ravencoin", - "networks": - [ + "networks": [ { "networkId": "ravencoin/test" } @@ -542,8 +543,7 @@ "id": "cosmos", "symbol": "ATOM", "name": "Cosmos Hub", - "networks": - [ + "networks": [ { "networkId": "cosmos/test" } @@ -798,6 +798,30 @@ "networkId": "alephium/test" } ] + }, + { + "id": "chainlink", + "name": "Chainlink", + "symbol": "LINK", + "networks": [ + { + "networkId": "ethereum/test", + "contractAddress": "0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5", + "decimalCount": 18 + } + ] + }, + { + "id": "gho", + "name": "GHO", + "symbol": "GHO", + "networks": [ + { + "networkId": "ethereum/test", + "contractAddress": "0xc4bF5CbDaBE595361438F8c6a187bDc330539c60", + "decimalCount": 18 + } + ] } ] } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 75a7c7f036..72c784bae5 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1213" +tangemBlockchainSdk = "develop-1216" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 39c859f73aa7e8751ff35d241d617b51c4b3fe8e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Sep 2025 09:55:24 +0400 Subject: [PATCH 048/132] Updated on 2026-08-14 --- .../tangem/tap/di/domain/NFTDomainModule.kt | 13 -- .../tap/di/domain/TokensDomainModule.kt | 33 +---- .../tap/di/domain/TransactionDomainModule.kt | 6 - .../tokens/DefaultTokensFeatureToggles.kt | 8 +- .../configs/feature_toggles_config.json | 4 - .../di/WalletConnectDataModule.kt | 10 -- .../pair/AssociateNetworksDelegate.kt | 16 +-- .../utils/WcNetworksConverter.kt | 16 +-- .../domain/nft/DisableWalletNFTUseCase.kt | 18 +-- .../domain/nft/FetchNFTCollectionsUseCase.kt | 18 +-- .../tangem/domain/nft/RefreshAllNFTUseCase.kt | 18 +-- .../tokens/AddCryptoCurrenciesUseCase.kt | 15 +-- .../tokens/ApplyTokenListSortingUseCase.kt | 13 +- .../tokens/FetchCurrencyStatusUseCase.kt | 15 +-- ...GetBalanceNotEnoughForFeeWarningUseCase.kt | 13 +- .../domain/tokens/GetCryptoCurrencyUseCase.kt | 19 +-- .../tokens/GetCurrencyWarningsUseCase.kt | 13 +- .../IsCryptoCurrencyCoinCouldHideUseCase.kt | 18 +-- ...RefreshMultiCurrencyWalletQuotesUseCase.kt | 19 +-- .../domain/tokens/RemoveCurrencyUseCase.kt | 18 +-- .../domain/tokens/TokensFeatureToggles.kt | 5 +- .../BaseCurrencyStatusOperations.kt | 51 +++---- .../CachedCurrenciesStatusesOperations.kt | 127 +----------------- .../ApplyTokenListSortingUseCaseTest.kt | 1 - .../usecase/AssociateAssetUseCase.kt | 30 ++--- .../send/v2/sendnft/model/NFTSendModel.kt | 23 ++-- .../deeplink/DefaultStakingDeepLinkHandler.kt | 21 +-- .../feature/swap/domain/SwapInteractorImpl.kt | 13 +- .../DefaultTokenDetailsDeepLinkHandler.kt | 25 ++-- .../wallet/child/wallet/model/WalletModel.kt | 44 ++---- .../model/intents/WalletClickIntents.kt | 45 +------ 31 files changed, 142 insertions(+), 546 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index 4d82335720..d16fc22bd8 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -6,7 +6,6 @@ import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -33,29 +32,21 @@ internal object NFTDomainModule { @Provides @Singleton fun providesFetchNFTCollectionsUseCase( - currenciesRepository: CurrenciesRepository, nftRepository: NFTRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): FetchNFTCollectionsUseCase = FetchNFTCollectionsUseCase( - currenciesRepository = currenciesRepository, nftRepository = nftRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides @Singleton fun providesRefreshAllNFTUseCase( - currenciesRepository: CurrenciesRepository, nftRepository: NFTRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): RefreshAllNFTUseCase = RefreshAllNFTUseCase( - currenciesRepository = currenciesRepository, nftRepository = nftRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides @@ -127,16 +118,12 @@ internal object NFTDomainModule { fun provideDisableWalletNFTUseCase( walletsRepository: WalletsRepository, nftRepository: NFTRepository, - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): DisableWalletNFTUseCase { return DisableWalletNFTUseCase( walletsRepository = walletsRepository, nftRepository = nftRepository, - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index bdbe9037cc..726ee06efc 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -47,7 +47,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher: MultiQuoteStatusFetcher, singleYieldBalanceFetcher: SingleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, stakingIdFactory: StakingIdFactory, ): AddCryptoCurrenciesUseCase { return AddCryptoCurrenciesUseCase( @@ -56,7 +55,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher = multiQuoteStatusFetcher, singleYieldBalanceFetcher = singleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, stakingIdFactory = stakingIdFactory, ) } @@ -111,13 +109,11 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, walletManagersFacade: WalletManagersFacade, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): RemoveCurrencyUseCase { return RemoveCurrencyUseCase( currenciesRepository = currenciesRepository, walletManagersFacade = walletManagersFacade, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -156,7 +152,6 @@ internal object TokensDomainModule { dispatchers: CoroutineDispatcherProvider, baseCurrencyStatusOperations: BaseCurrencyStatusOperations, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): GetCurrencyWarningsUseCase { return GetCurrencyWarningsUseCase( walletManagersFacade = walletManagersFacade, @@ -165,7 +160,6 @@ internal object TokensDomainModule { currencyChecksRepository = currencyChecksRepository, currencyStatusOperations = baseCurrencyStatusOperations, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -177,7 +171,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher: MultiQuoteStatusFetcher, singleYieldBalanceFetcher: SingleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, stakingIdFactory: StakingIdFactory, ): FetchCurrencyStatusUseCase { return FetchCurrencyStatusUseCase( @@ -186,7 +179,6 @@ internal object TokensDomainModule { multiQuoteStatusFetcher = multiQuoteStatusFetcher, singleYieldBalanceFetcher = singleYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, stakingIdFactory = stakingIdFactory, ) } @@ -214,9 +206,8 @@ internal object TokensDomainModule { fun provideGetCryptoCurrencyUseCase( currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): GetCryptoCurrencyUseCase { - return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier, tokensFeatureToggles) + return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier) } @Provides @@ -238,13 +229,11 @@ internal object TokensDomainModule { fun provideApplyTokenListSortingUseCase( currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, dispatchers: CoroutineDispatcherProvider, ): ApplyTokenListSortingUseCase { return ApplyTokenListSortingUseCase( currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, dispatchers = dispatchers, ) } @@ -304,14 +293,10 @@ internal object TokensDomainModule { @Provides @Singleton fun provideIsCryptoCurrencyCoinCouldHideUseCase( - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): IsCryptoCurrencyCoinCouldHideUseCase { return IsCryptoCurrencyCoinCouldHideUseCase( - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -330,13 +315,11 @@ internal object TokensDomainModule { fun provideGetBalanceNotEnoughForFeeWarningUseCase( currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, dispatchers: CoroutineDispatcherProvider, ): GetBalanceNotEnoughForFeeWarningUseCase { return GetBalanceNotEnoughForFeeWarningUseCase( currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, dispatchers = dispatchers, ) } @@ -377,16 +360,12 @@ internal object TokensDomainModule { @Provides @Singleton fun provideRefreshMultiCurrencyWalletQuotesUseCase( - currenciesRepository: CurrenciesRepository, multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): RefreshMultiCurrencyWalletQuotesUseCase { return RefreshMultiCurrencyWalletQuotesUseCase( - currenciesRepository = currenciesRepository, multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } @@ -402,18 +381,13 @@ internal object TokensDomainModule { @Provides @Singleton fun provideBaseCurrencyStatusOperations( - tokensFeatureToggles: TokensFeatureToggles, currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, - multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - multiQuoteStatusFetcher: MultiQuoteStatusFetcher, singleQuoteStatusSupplier: SingleQuoteStatusSupplier, singleYieldBalanceSupplier: SingleYieldBalanceSupplier, multiYieldBalanceSupplier: MultiYieldBalanceSupplier, - multiYieldBalanceFetcher: MultiYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, stakingIdFactory: StakingIdFactory, ): BaseCurrencyStatusOperations { @@ -422,15 +396,10 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiNetworkStatusSupplier = multiNetworkStatusSupplier, - multiNetworkStatusFetcher = multiNetworkStatusFetcher, - singleNetworkStatusFetcher = singleNetworkStatusFetcher, - multiQuoteStatusFetcher = multiQuoteStatusFetcher, singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleYieldBalanceSupplier = singleYieldBalanceSupplier, multiYieldBalanceSupplier = multiYieldBalanceSupplier, - multiYieldBalanceFetcher = multiYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, stakingIdFactory = stakingIdFactory, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 0b8802198c..96c968aa66 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -6,8 +6,6 @@ import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository @@ -63,18 +61,14 @@ internal object TransactionDomainModule { fun provideAssociateAssetUseCase( cardSdkConfigRepository: CardSdkConfigRepository, walletManagersFacade: WalletManagersFacade, - currenciesRepository: CurrenciesRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): AssociateAssetUseCase { return AssociateAssetUseCase( cardSdkConfigRepository = cardSdkConfigRepository, walletManagersFacade = walletManagersFacade, - currenciesRepository = currenciesRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt index 49ac95c749..444b1f4032 100644 --- a/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt +++ b/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt @@ -4,9 +4,5 @@ import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.tokens.TokensFeatureToggles internal class DefaultTokensFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : TokensFeatureToggles { - - override val isWalletBalanceFetcherEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "WALLET_BALANCE_FETCHER_ENABLED") -} \ No newline at end of file + @Suppress("UnusedPrivateMember") private val featureTogglesManager: FeatureTogglesManager, +) : TokensFeatureToggles \ 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 3cf7a9e285..40b69242c9 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 @@ -43,10 +43,6 @@ "name": "SEND_REDESIGN_ENABLED", "version": "5.28.0" }, - { - "name": "WALLET_BALANCE_FETCHER_ENABLED", - "version": "5.27.0" - }, { "name": "HOT_WALLET_ENABLED", "version": "undefined" diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index 0ee7dab28e..0fe3412528 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -21,8 +21,6 @@ import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.WcRequestUseCaseFactory @@ -177,15 +175,11 @@ internal object WalletConnectDataModule { fun wcNetworksConverter( namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>, walletManagersFacade: WalletManagersFacade, - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): WcNetworksConverter = WcNetworksConverter( namespaceConverters = namespaceConverters, walletManagersFacade = walletManagersFacade, - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides @@ -193,15 +187,11 @@ internal object WalletConnectDataModule { fun associateNetworksDelegate( namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>, getWallets: GetWalletsUseCase, - currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles: TokensFeatureToggles, ): AssociateNetworksDelegate = AssociateNetworksDelegate( namespaceConverters = namespaceConverters, getWallets = getWallets, - currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, - tokensFeatureToggles = tokensFeatureToggles, ) @Provides diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt index d0f06c74b2..705efb5df5 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt @@ -10,8 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -19,9 +17,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase internal class AssociateNetworksDelegate( private val namespaceConverters: Set, private val getWallets: GetWalletsUseCase, - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { @Throws(WcPairError.UnsupportedBlockchains::class) @@ -96,14 +92,10 @@ internal class AssociateNetworksDelegate( } private suspend fun getWalletNetworks(userWalletId: UserWalletId): List { - return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), + ) + .orEmpty() .filterIsInstance() .map(CryptoCurrency.Coin::network) // flatten all derivation diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt index a7d1d285fb..9d998db372 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt @@ -9,8 +9,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionApprove import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest @@ -20,9 +18,7 @@ import javax.inject.Inject internal class WcNetworksConverter @Inject constructor( private val namespaceConverters: Set, private val walletManagersFacade: WalletManagersFacade, - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { fun createNetwork(chainId: String, wallet: UserWallet): Network? { @@ -101,12 +97,10 @@ internal class WcNetworksConverter @Inject constructor( } private suspend fun getWalletNetworks(userWalletId: UserWalletId): List { - return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), - ).orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - }.filterIsInstance().map(CryptoCurrency.Coin::network) + return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), + ) + .orEmpty() + .filterIsInstance().map(CryptoCurrency.Coin::network) } } \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt index 32f37d3fe6..c671f030a9 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/DisableWalletNFTUseCase.kt @@ -1,32 +1,24 @@ package com.tangem.domain.nft +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.repository.WalletsRepository class DisableWalletNFTUseCase( private val walletsRepository: WalletsRepository, private val nftRepository: NFTRepository, - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId) { walletsRepository.disableNFT(userWalletId) - val currencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCachedCurrenciesSync(userWalletId) - } + val currencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val networks = currencies.map { it.network } nftRepository.clearCache(userWalletId, networks) diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt index 421ae063af..b2b8d20503 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt @@ -1,28 +1,20 @@ package com.tangem.domain.nft +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId class FetchNFTCollectionsUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val nftRepository: NFTRepository, ) { suspend operator fun invoke(userWalletId: UserWalletId) { - val currencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val currencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() nftRepository.refreshCollections(userWalletId, currencies.map { it.network }.distinct()) } diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt index b274246dcc..a3cba338d2 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt @@ -1,29 +1,21 @@ package com.tangem.domain.nft import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId class RefreshAllNFTUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val nftRepository: NFTRepository, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either = Either.catch { - val currencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val currencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() nftRepository.refreshAll(userWalletId, currencies.map { it.network }.distinct()) } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt index 06b498b2ab..7223a6faae 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt @@ -33,7 +33,6 @@ class AddCryptoCurrenciesUseCase( private val singleYieldBalanceFetcher: SingleYieldBalanceFetcher, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -92,15 +91,11 @@ class AddCryptoCurrenciesUseCase( ): Either = either { val existingCurrencies = catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - .toList() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() + .toList() }, catch = ::raise, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt index 8fbd310f2f..1bad208906 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.withContext class ApplyTokenListSortingUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -88,14 +87,10 @@ class ApplyTokenListSortingUseCase( private suspend fun Raise.getCurrencies(userWalletId: UserWalletId): List { val tokens = catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId, refresh = false) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() }, catch = { raise(TokenListSortingError.DataError(it)) }, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt index e500c445fb..5c7ce2a444 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt @@ -35,7 +35,6 @@ class FetchCurrencyStatusUseCase( private val singleYieldBalanceFetcher: SingleYieldBalanceFetcher, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -97,15 +96,11 @@ class FetchCurrencyStatusUseCase( ): CryptoCurrency { return catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id == id } - ?: error("Unable to find currency with ID: $id") - } else { - currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId = userWalletId, id = id) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it.id == id } + ?: error("Unable to find currency with ID: $id") }, ) { raise(CurrencyStatusError.DataError(it)) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt index bc8e4fa72d..26ed8b9867 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt @@ -26,7 +26,6 @@ import java.math.BigDecimal class GetBalanceNotEnoughForFeeWarningUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, ) { suspend operator fun invoke( @@ -71,14 +70,10 @@ class GetBalanceNotEnoughForFeeWarningUseCase( tokenStatus: CryptoCurrencyStatus, feePaidToken: FeePaidCurrency.Token, ): CryptoCurrencyWarning { - val tokens = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val token = tokens.find { it is CryptoCurrency.Token && diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt index 67780eb049..15b876dd8f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt @@ -5,16 +5,15 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.error.CurrencyStatusError -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.error.CurrencyStatusError +import com.tangem.domain.tokens.repository.CurrenciesRepository class GetCryptoCurrencyUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -55,15 +54,11 @@ class GetCryptoCurrencyUseCase( ): CryptoCurrency { return catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id.value == id } - ?: error("Unable to find currency with ID: $id") - } else { - currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, id) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it.id.value == id } + ?: error("Unable to find currency with ID: $id") }, catch = { raise(CurrencyStatusError.DataError(it)) }, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index 6b64410367..d4611e9d40 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -29,7 +29,6 @@ class GetCurrencyWarningsUseCase( private val currencyChecksRepository: CurrencyChecksRepository, private val currencyStatusOperations: BaseCurrencyStatusOperations, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -152,14 +151,10 @@ class GetCurrencyWarningsUseCase( tokenStatus: CryptoCurrencyStatus, feePaidToken: FeePaidCurrency.Token, ): CryptoCurrencyWarning { - val tokens = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val token = tokens.find { it is CryptoCurrency.Token && diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt index 5a0da52389..5ea612a931 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsCryptoCurrencyCoinCouldHideUseCase.kt @@ -1,27 +1,17 @@ package com.tangem.domain.tokens import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.models.wallet.UserWalletId class IsCryptoCurrencyCoinCouldHideUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId, cryptoCurrencyCoin: CryptoCurrency.Coin): Boolean { - return if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync( - userWalletId = userWalletId, - refresh = false, - ) - } + return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() .none { it is CryptoCurrency.Token && it.network == cryptoCurrencyCoin.network } } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt index ce4c325973..2fc30f9448 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt @@ -5,19 +5,16 @@ import arrow.core.getOrElse import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.tokens.error.QuotesError -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope class RefreshMultiCurrencyWalletQuotesUseCase( - private val currenciesRepository: CurrenciesRepository, private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either { @@ -41,15 +38,11 @@ class RefreshMultiCurrencyWalletQuotesUseCase( return either { catch( block = { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - .toList() - } else { - currenciesRepository.getMultiCurrencyWalletCachedCurrenciesSync(userWalletId) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() + .toList() }, catch = ::raise, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt index 95fec7162c..59381c5e65 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt @@ -4,16 +4,15 @@ import arrow.core.Either import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.remove.RemoveCurrencyError import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.models.wallet.UserWalletId class RemoveCurrencyUseCase( private val currenciesRepository: CurrenciesRepository, private val walletManagersFacade: WalletManagersFacade, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -46,17 +45,10 @@ class RemoveCurrencyUseCase( suspend fun hasLinkedTokens(userWalletId: UserWalletId, currency: CryptoCurrency): Boolean { return when (currency) { is CryptoCurrency.Coin -> { - val walletCurrencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync( - userWalletId = userWalletId, - refresh = false, - ) - } + val walletCurrencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() walletCurrencies.any { it is CryptoCurrency.Token && it.network == currency.network } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt index 5add2d0b65..a2eeb1e0a6 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt @@ -5,7 +5,4 @@ package com.tangem.domain.tokens * [REDACTED_AUTHOR] */ -interface TokensFeatureToggles { - - val isWalletBalanceFetcherEnabled: Boolean -} \ No newline at end of file +interface TokensFeatureToggles \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt index 3a258c4cf6..55d53e54c8 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt @@ -28,7 +28,6 @@ import com.tangem.domain.staking.single.SingleYieldBalanceProducer import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -53,7 +52,6 @@ abstract class BaseCurrencyStatusOperations( private val multiYieldBalanceSupplier: MultiYieldBalanceSupplier, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) { protected val currencyStatusProxyCreator = CurrencyStatusProxyCreator() @@ -62,13 +60,6 @@ abstract class BaseCurrencyStatusOperations( protected abstract fun getQuotes(id: CryptoCurrency.RawID): Flow>> - protected abstract suspend fun fetchComponents( - userWalletId: UserWalletId, - networks: Set, - currenciesIds: Set, - currencies: List, - ): Either - suspend fun getCurrencyStatusFlow( userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, @@ -262,14 +253,10 @@ abstract class BaseCurrencyStatusOperations( return either { catch( block = { - val nonEmptyCurrencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.toNonEmptyListOrNull() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId).toNonEmptyListOrNull() - } + val nonEmptyCurrencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.toNonEmptyListOrNull() ?: return emptyList().right() val (_, currenciesIds) = getIds(nonEmptyCurrencies) @@ -332,15 +319,11 @@ abstract class BaseCurrencyStatusOperations( currencyId: CryptoCurrency.ID, ): CryptoCurrency { return Either.catch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it.id == currencyId } - ?: error("Unable to find currency with ID: $currencyId") - } else { - currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId = userWalletId, id = currencyId) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it.id == currencyId } + ?: error("Unable to find currency with ID: $currencyId") } .mapLeft(Error::DataError) .bind() @@ -382,16 +365,12 @@ abstract class BaseCurrencyStatusOperations( derivationPath: Network.DerivationPath, ): CryptoCurrency { return Either.catch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.filterIsInstance() - ?.firstOrNull { it.network.id == networkId } - ?: error("Unable to create network coin with ID: $networkId") - } else { - currenciesRepository.getNetworkCoin(userWalletId, networkId, derivationPath) - } + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.filterIsInstance() + ?.firstOrNull { it.network.id == networkId && it.network.derivationPath == derivationPath } + ?: error("Unable to create network coin with ID: $networkId") } .mapLeft { Error.DataError(it) } .bind() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt index 70700beea8..ef89e15b90 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt @@ -1,7 +1,6 @@ package com.tangem.domain.tokens.operations import arrow.core.* -import arrow.core.raise.either import arrow.core.raise.recover import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow @@ -18,31 +17,27 @@ import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier -import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.quotes.QuotesRepository -import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.model.StakingIntegrationID -import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier import com.tangem.domain.staking.single.SingleYieldBalanceProducer import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.utils.extractAddress import com.tangem.utils.extensions.addOrReplace -import com.tangem.utils.extensions.isSingleItem -import kotlinx.coroutines.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch @Suppress("LongParameterList", "LargeClass") class CachedCurrenciesStatusesOperations( @@ -50,16 +45,11 @@ class CachedCurrenciesStatusesOperations( quotesRepository: QuotesRepository, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, - private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, multiYieldBalanceSupplier: MultiYieldBalanceSupplier, - private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val stakingIdFactory: StakingIdFactory, - private val tokensFeatureToggles: TokensFeatureToggles, ) : BaseCurrencyStatusOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, @@ -70,7 +60,6 @@ class CachedCurrenciesStatusesOperations( multiYieldBalanceSupplier = multiYieldBalanceSupplier, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, stakingIdFactory = stakingIdFactory, - tokensFeatureToggles = tokensFeatureToggles, ) { override fun getCurrenciesStatuses( @@ -90,20 +79,6 @@ class CachedCurrenciesStatusesOperations( ): LceFlow> = lceFlow { val prevStatuses = MutableStateFlow(value = emptyList()) - val nonEmptyCurrencies = currenciesFlow.mapNotNull { it.getOrNull() }.firstOrNull()?.toNonEmptyListOrNull() - - if (!tokensFeatureToggles.isWalletBalanceFetcherEnabled && !isFetchingStarted(userWalletId) && - nonEmptyCurrencies != null - ) { - launch { - setFetchStarted(userWalletId) - - val (networks, currenciesIds) = getIds(nonEmptyCurrencies) - fetchComponents(userWalletId, networks, currenciesIds, nonEmptyCurrencies) - } - .invokeOnCompletion { setFetchFinished(userWalletId) } - } - currenciesFlow.flatMapLatest { maybeCurrencies -> val currencies = maybeCurrencies .getOrElse { return@flatMapLatest flowOf(it.lceError()) } @@ -154,15 +129,6 @@ class CachedCurrenciesStatusesOperations( ) } - if (!tokensFeatureToggles.isWalletBalanceFetcherEnabled && !isFetchingStarted(userWalletId)) { - launch { - setFetchStarted(userWalletId) - - fetchComponents(userWalletId, networks, currenciesIds, currencies) - } - .invokeOnCompletion { setFetchFinished(userWalletId) } - } - val networksStatusesUpdates = getNetworkStatusesUpdates(userWalletId, networks) combine( @@ -183,11 +149,7 @@ class CachedCurrenciesStatusesOperations( getYieldsBalancesUpdates(userWalletId, currenciesAddresses) }, - flow4 = fetchingState.map { - val state = it[userWalletId] ?: return@map false - - !state.isFinished() - }, + flow4 = flowOf(value = false), transform = ::createCurrenciesStatuses, ) .distinctUntilChanged() @@ -200,55 +162,6 @@ class CachedCurrenciesStatusesOperations( .launchIn(scope = this) } - override suspend fun fetchComponents( - userWalletId: UserWalletId, - networks: Set, - currenciesIds: Set, - currencies: List, - ): Either = either { - coroutineScope { - awaitAll( - async { - if (networks.isSingleItem()) { - singleNetworkStatusFetcher( - params = SingleNetworkStatusFetcher.Params( - userWalletId = userWalletId, - network = networks.first(), - ), - ) - } else { - multiNetworkStatusFetcher( - params = MultiNetworkStatusFetcher.Params( - userWalletId = userWalletId, - networks = networks, - ), - ) - } - }, - async { - val rawCurrenciesIds = currenciesIds.mapNotNullTo(mutableSetOf()) { it.rawCurrencyId } - - multiQuoteStatusFetcher( - params = MultiQuoteStatusFetcher.Params(currenciesIds = rawCurrenciesIds, appCurrencyId = null), - ) - }, - async { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - multiYieldBalanceFetcher( - params = MultiYieldBalanceFetcher.Params( - userWalletId = userWalletId, - stakingIds = stakingIds, - ), - ) - }, - ) - } - .map { } - } - private fun createCurrenciesStatuses( currencies: NonEmptyList, maybeQuotes: Either>?, @@ -428,36 +341,4 @@ class CachedCurrenciesStatusesOperations( } .distinctUntilChanged() } - - private fun isFetchingStarted(userWalletId: UserWalletId): Boolean { - return fetchingState.value[userWalletId]?.let { it.isStarted() || it.isFinished() } == true - } - - private fun setFetchStarted(userWalletId: UserWalletId) { - fetchingState.update { - it.toMutableMap().apply { - put(key = userWalletId, value = FetchingState.STARTED) - } - } - } - - private fun setFetchFinished(userWalletId: UserWalletId) { - fetchingState.update { - it.toMutableMap().apply { - put(key = userWalletId, value = FetchingState.FINISHED) - } - } - } - - enum class FetchingState { - STARTED, FINISHED; - - fun isStarted() = this == STARTED - fun isFinished() = this == FINISHED - } - - companion object { - - private val fetchingState = MutableStateFlow(value = emptyMap()) - } } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt index b0025a5e46..be67de0762 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCaseTest.kt @@ -188,7 +188,6 @@ internal class ApplyTokenListSortingUseCaseTest { currenciesRepository = tokensRepository, dispatchers = TestingCoroutineDispatcherProvider(), multiWalletCryptoCurrenciesSupplier = mockk(), - tokensFeatureToggles = mockk(), ) private fun getTokensRepository( diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt index 322f65b335..f528a1c75a 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt @@ -12,8 +12,6 @@ import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.error.AssociateAssetError import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.isNullOrZero @@ -22,10 +20,8 @@ import kotlinx.coroutines.flow.firstOrNull class AssociateAssetUseCase( private val cardSdkConfigRepository: CardSdkConfigRepository, private val walletManagersFacade: WalletManagersFacade, - private val currenciesRepository: CurrenciesRepository, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -33,25 +29,19 @@ class AssociateAssetUseCase( currency: CryptoCurrency, ): Either { return either { - val networkCoin = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { - val network = currency.network - it.network.id == network.id && it.network.derivationPath == network.derivationPath - } - ?: error("Unable to create network coin for currencyID: ${currency.id}") - } else { - currenciesRepository.getNetworkCoin( - userWalletId = userWalletId, - networkId = currency.network.id, - derivationPath = currency.network.derivationPath, - ) - } + val networkCoin = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { + val network = currency.network + it.network.id == network.id && it.network.derivationPath == network.derivationPath + } + ?: error("Unable to create network coin for currencyID: ${currency.id}") + if (isBalanceZero(userWalletId, networkCoin)) { raise(AssociateAssetError.NotEnoughBalance(networkCoin)) } + val signer = cardSdkConfigRepository.getCommonSigner( cardId = null, twinKey = null, // use null here because no assets support for Twin cards diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt index 6f1080a389..9660b61db5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -22,7 +22,10 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.* +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase @@ -62,9 +65,7 @@ internal class NFTSendModel @Inject constructor( private val router: Router, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, - private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase, @@ -178,16 +179,10 @@ internal class NFTSendModel @Inject constructor( ifRight = { wallet -> userWallet = wallet - cryptoCurrency = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - ?.firstOrNull { it is CryptoCurrency.Coin && it.network == nftAsset.network } - } else { - getCryptoCurrenciesUseCase(userWalletId).getOrNull() - ?.filterIsInstance() - ?.firstOrNull { it.network == nftAsset.network } - } + cryptoCurrency = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + ?.firstOrNull { it is CryptoCurrency.Coin && it.network == nftAsset.network } ?: return@launch getCurrenciesStatusUpdates( @@ -244,7 +239,7 @@ internal class NFTSendModel @Inject constructor( ).getOrNull() ?: cryptoStatus if (uiState.value.destinationUM is DestinationUM.Empty) { - router.replaceAll(CommonSendRoute.Destination(isEditMode = false)) + router.replaceAll(Destination(isEditMode = false)) } }, ifLeft = { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index 575ee282d4..cae468e2b2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -6,14 +6,12 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetYieldUseCase import com.tangem.domain.staking.model.StakingAvailability -import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.TokensFeatureToggles -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import dagger.assisted.Assisted @@ -29,9 +27,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( @Assisted private val queryParams: Map, private val appRouter: AppRouter, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val getYieldUseCase: GetYieldUseCase, private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, ) : StakingDeepLinkHandler { @@ -56,17 +52,10 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( } scope.launch { - val cryptoCurrency = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId), - ) - .orEmpty() - } else { - getCryptoCurrenciesUseCase(userWalletId = selectedUserWalletId).getOrElse { - Timber.e("Error on getting crypto currency list") - return@launch - } - } + val cryptoCurrency = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId), + ) + .orEmpty() .firstOrNull { val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index de44a22010..97a6e45689 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -72,7 +72,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val appCurrencyRepository: AppCurrencyRepository, private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val tokensFeatureToggles: TokensFeatureToggles, private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, @@ -1761,14 +1760,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val tokens = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - } else { - currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) - } + val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + ) + .orEmpty() val token = tokens .filterIsInstance() diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index a88858e751..c8458fac52 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -12,13 +12,15 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.notifications.models.NotificationType -import com.tangem.domain.tokens.* -import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.notifications.models.NotificationType +import com.tangem.domain.tokens.FetchCurrencyStatusUseCase +import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyUseCase +import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents @@ -45,9 +47,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger, private val analyticsEventHandler: AnalyticsEventHandler, private val getUserWalletUseCase: GetUserWalletUseCase, - private val tokensFeatureToggles: TokensFeatureToggles, private val walletBalanceFetcher: WalletBalanceFetcher, - private val fetchCardTokenListUseCase: FetchCardTokenListUseCase, ) : TokenDetailsDeepLinkHandler { init { @@ -119,24 +119,15 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) { val isMultiCurrency = userWallet.isMultiCurrency // single-currency wallet with token (NODL) - val isSingleWalletWithToken = userWallet is UserWallet.Cold && + userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() when { isMultiCurrency -> fetchCurrencyStatusUseCase.invoke( userWalletId = userWallet.walletId, id = cryptoCurrency.id, ) - !isMultiCurrency && tokensFeatureToggles.isWalletBalanceFetcherEnabled -> - walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId)) - // remove below after delete tokensFeatureToggles.isWalletBalanceFetcherEnabled - !isMultiCurrency && userWallet is UserWallet.Cold && isSingleWalletWithToken -> - fetchCardTokenListUseCase.invoke( - userWalletId = userWallet.walletId, - refresh = true, - ) - !isMultiCurrency -> fetchCurrencyStatusUseCase.invoke( - userWalletId = userWallet.walletId, - refresh = true, + !isMultiCurrency -> walletBalanceFetcher( + params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId), ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index d90595d32f..7e4574513d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -10,7 +10,6 @@ import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked @@ -20,9 +19,7 @@ import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUse import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.* -import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.wallets.usecase.* import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.router.InnerWalletRouter @@ -76,9 +73,7 @@ internal class WalletModel @Inject constructor( private val tokenListStore: MultiWalletTokenListStore, private val onrampStatusFactory: OnrampStatusFactory, private val analyticsEventsHandler: AnalyticsEventHandler, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val walletContentFetcher: WalletContentFetcher, - private val tokensFeatureToggles: TokensFeatureToggles, private val observeAndClearNFTCacheIfNeedUseCase: ObserveAndClearNFTCacheIfNeedUseCase, private val walletDeepLinkActionListener: WalletDeepLinkActionListener, private val notificationsRepository: NotificationsRepository, @@ -406,13 +401,11 @@ internal class WalletModel @Inject constructor( val otherWallets = action.wallets.minus(action.selectedWallet) - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - otherWallets - .filterNot(UserWallet::isLocked) - .onEach { userWallet -> - modelScope.launch { walletContentFetcher(userWalletId = userWallet.walletId) } - } - } + otherWallets + .filterNot(UserWallet::isLocked) + .onEach { userWallet -> + modelScope.launch { walletContentFetcher(userWalletId = userWallet.walletId) } + } if (action.wallets.size > 1 && isWalletsScrollPreviewEnabled()) { val direction = if (action.selectedWalletIndex == action.wallets.lastIndex) { @@ -570,27 +563,14 @@ internal class WalletModel @Inject constructor( } private suspend fun fetchWalletContent(userWallet: UserWallet) { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - if (userWallet.isLocked) return + if (userWallet.isLocked) return - /* - * Updating the balance of the current wallet is an essential part of InitializationWallets, - * so the coroutine is launched in the current context - */ - supervisorScope { - launch { walletContentFetcher(userWalletId = userWallet.walletId) } - } - } else { - fetchIfSingleWallet(userWallet = userWallet) - } - } - - private fun fetchIfSingleWallet(userWallet: UserWallet) { - if (userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWallet()) { - modelScope.launch { - fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId) - .onLeft { Timber.e(it.toString()) } - } + /* + * Updating the balance of the current wallet is an essential part of InitializationWallets, + * so the coroutine is launched in the current context + */ + supervisorScope { + launch { walletContentFetcher(userWalletId = userWallet.walletId) } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt index 55fd1893e3..115b1114ba 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt @@ -1,18 +1,10 @@ package com.tangem.feature.wallet.child.wallet.model.intents import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.appcurrency.extenstions.unwrap -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.exchange.RampStateManager -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.onramp.FetchHotCryptoUseCase import com.tangem.domain.settings.NeverToShowWalletsScrollPreview -import com.tangem.domain.tokens.FetchCardTokenListUseCase -import com.tangem.domain.tokens.FetchCurrencyStatusUseCase -import com.tangem.domain.tokens.FetchTokenListUseCase -import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.feature.wallet.presentation.router.InnerWalletRouter @@ -23,7 +15,6 @@ import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContent import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetRefreshStateTransformer -import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTokenListErrorTransformer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -43,12 +34,7 @@ internal class WalletClickIntents @Inject constructor( private val walletScreenContentLoader: WalletScreenContentLoader, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val selectWalletUseCase: SelectWalletUseCase, - private val fetchTokenListUseCase: FetchTokenListUseCase, private val walletContentFetcher: WalletContentFetcher, - private val tokensFeatureToggles: TokensFeatureToggles, - private val fetchCardTokenListUseCase: FetchCardTokenListUseCase, - private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val neverToShowWalletsScrollPreview: NeverToShowWalletsScrollPreview, private val rampStateManager: RampStateManager, private val fetchHotCryptoUseCase: FetchHotCryptoUseCase, @@ -88,7 +74,7 @@ internal class WalletClickIntents @Inject constructor( stateHolder.update { it.copy(selectedWalletIndex = index) } maybeUserWallet.onRight { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled && !it.isLocked) { + if (!it.isLocked) { launch { walletContentFetcher(userWalletId = it.walletId) } } @@ -131,28 +117,7 @@ internal class WalletClickIntents @Inject constructor( ) modelScope.launch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) - } else { - val isSingleWalletWithToken = userWallet is UserWallet.Cold && - userWallet.cardTypesResolver.isSingleWalletWithToken() - - val maybeFetchResult = if (isSingleWalletWithToken) { - fetchCardTokenListUseCase(userWalletId = userWallet.walletId, refresh = true) - } else { - fetchTokenListUseCase(userWalletId = userWallet.walletId) - } - - maybeFetchResult.onLeft { - stateHolder.update( - SetTokenListErrorTransformer( - selectedWallet = userWallet, - error = it, - appCurrency = getSelectedAppCurrencyUseCase.unwrap(), - ), - ) - } - } + walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) buildList { async { rampStateManager.fetchSellServiceData() }.let(::add) @@ -177,11 +142,7 @@ internal class WalletClickIntents @Inject constructor( ) modelScope.launch { - if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) { - walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) - } else { - fetchCurrencyStatusUseCase(userWallet.walletId, refresh = true) - } + walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = true) onrampStatusFactory.updateOnrmapTransactionStatuses(userWallet) walletScreenContentLoader.load( From e590fffa8bc65855b9d35d004404f5509225fa07 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Sep 2025 09:45:57 +0000 Subject: [PATCH 049/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 5ebc59103a..72c784bae5 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1214" +tangemBlockchainSdk = "develop-1216" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From e323c7cc4a10f2433bbc7a033624103cf9f077f8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Sep 2025 16:21:48 +0400 Subject: [PATCH 050/132] Updated on 2026-08-14 --- app/build.gradle.kts | 1 - .../di/domain/NotificationsDomainModule.kt | 9 ------- .../DefaultNotificationsFeatureToggles.kt | 11 -------- .../tangem/tap/features/main/MainViewModel.kt | 18 ++++++------- .../configs/feature_toggles_config.json | 4 --- data/common/build.gradle.kts | 1 - .../UserTokensResponseAddressesEnricher.kt | 6 ----- .../tangem/data/common/di/DataCommonModule.kt | 3 --- ...UserTokensResponseAddressesEnricherTest.kt | 8 ------ domain/notifications/toggles/.gitignore | 1 - domain/notifications/toggles/build.gradle.kts | 4 --- .../toggles/NotificationsFeatureToggles.kt | 5 ---- .../push-notifications/impl/build.gradle.kts | 1 - ...ltPushNotificationsBottomSheetComponent.kt | 4 --- .../impl/DefaultPushNotificationsComponent.kt | 6 ++--- .../impl/model/PushNotificationsModel.kt | 26 ++++--------------- .../presentation/state/PushNotificationsUM.kt | 5 ---- .../ui/PushNotificationsBottomSheet.kt | 19 ++++---------- .../ui/PushNotificationsScreen.kt | 18 ++++--------- .../wallet-settings/impl/build.gradle.kts | 1 - .../model/WalletSettingsModel.kt | 5 +--- features/wallet/impl/build.gradle.kts | 1 - .../wallet/child/wallet/model/WalletModel.kt | 3 --- settings.gradle.kts | 1 - 24 files changed, 27 insertions(+), 134 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/domain/notifications/DefaultNotificationsFeatureToggles.kt delete mode 100644 domain/notifications/toggles/.gitignore delete mode 100644 domain/notifications/toggles/build.gradle.kts delete mode 100644 domain/notifications/toggles/src/main/java/com/tangem/domain/notifications/toggles/NotificationsFeatureToggles.kt delete mode 100644 features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/state/PushNotificationsUM.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 720052668f..a5fdb2e580 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,7 +149,6 @@ dependencies { implementation(projects.domain.quotes) implementation(projects.domain.notifications) implementation(projects.domain.notifications.models) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.swap.models) implementation(projects.domain.swap) implementation(projects.domain.walletManager) diff --git a/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt index b35ae23d95..5e28ca4628 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt @@ -1,11 +1,8 @@ package com.tangem.tap.di.domain -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.notifications.* import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.repository.PushNotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles -import com.tangem.tap.domain.notifications.DefaultNotificationsFeatureToggles import com.tangem.utils.notifications.PushNotificationsTokenProvider import dagger.Module import dagger.Provides @@ -79,12 +76,6 @@ internal object NotificationsDomainModule { ) } - @Provides - @Singleton - fun provideNotificationsFeatureToggles(featureTogglesManager: FeatureTogglesManager): NotificationsFeatureToggles { - return DefaultNotificationsFeatureToggles(featureTogglesManager = featureTogglesManager) - } - @Provides @Singleton fun provideGetNetworksAvailableForNotifications( diff --git a/app/src/main/java/com/tangem/tap/domain/notifications/DefaultNotificationsFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/notifications/DefaultNotificationsFeatureToggles.kt deleted file mode 100644 index 5a58923bb9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/notifications/DefaultNotificationsFeatureToggles.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.domain.notifications - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles - -internal class DefaultNotificationsFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : NotificationsFeatureToggles { - override val isNotificationsEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled("PUSH_NOTIFICATIONS_ENABLED") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 777ff7cc9a..5fba945d69 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -28,7 +28,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.GetApplicationIdUseCase import com.tangem.domain.notifications.SendPushTokenUseCase import com.tangem.domain.notifications.models.ApplicationId -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.onramp.FetchHotCryptoUseCase import com.tangem.domain.promo.GetStoryContentUseCase @@ -80,7 +79,6 @@ internal class MainViewModel @Inject constructor( private val imagePreloader: ImagePreloader, private val fetchHotCryptoUseCase: FetchHotCryptoUseCase, private val onboardingRepository: OnboardingRepository, - private val notificationsToggles: NotificationsFeatureToggles, private val getApplicationIdUseCase: GetApplicationIdUseCase, private val subscribeOnWalletsUseCase: GetSavedWalletsCountUseCase, private val associateWalletsWithApplicationIdUseCase: AssociateWalletsWithApplicationIdUseCase, @@ -415,18 +413,20 @@ internal class MainViewModel @Inject constructor( } private suspend fun initPushNotifications() { - if (notificationsToggles.isNotificationsEnabled) { - getApplicationIdUseCase().onRight { applicationId -> + getApplicationIdUseCase() + .onRight { applicationId -> sendPushTokenUseCase(applicationId = applicationId) associateWalletsWithApplicationId(applicationId = applicationId) updateRemoteWalletsInfoUseCase(applicationId = applicationId) - }.onLeft { Timber.e(it.toString()) } - } + } + .onLeft(Timber::e) } private fun associateWalletsWithApplicationId(applicationId: ApplicationId) { - subscribeOnWalletsUseCase().onEach { wallets -> - associateWalletsWithApplicationIdUseCase(applicationId, wallets) - }.launchIn(viewModelScope) + subscribeOnWalletsUseCase() + .onEach { wallets -> + associateWalletsWithApplicationIdUseCase(applicationId, wallets) + } + .launchIn(viewModelScope) } } \ 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 89dcd4edea..ec0d4d8507 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 @@ -23,10 +23,6 @@ "name": "WALLET_CONNECT_REDESIGN_ENABLED", "version": "5.27.0" }, - { - "name": "PUSH_NOTIFICATIONS_ENABLED", - "version": "5.26.2" - }, { "name": "USEDESK_ENABLED", "version": "undefined" diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index 6402820dd1..ece3e5297a 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.networks) implementation(projects.domain.wallets) diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt index 96f45165c5..2b155a84fc 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt @@ -5,7 +5,6 @@ import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.first @@ -15,17 +14,12 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.seconds class UserTokensResponseAddressesEnricher @Inject constructor( - private val notificationsFeatureToggles: NotificationsFeatureToggles, private val walletsRepository: WalletsRepository, private val dispatchers: CoroutineDispatcherProvider, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, ) { suspend operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse { - if (!notificationsFeatureToggles.isNotificationsEnabled) { - return response - } - val isNotificationsEnabled = walletsRepository.isNotificationsEnabled(userWalletId) return withContext(dispatchers.default) { diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 2e6266a009..85260e75da 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -9,7 +9,6 @@ import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -42,13 +41,11 @@ internal object DataCommonModule { @Provides @Singleton fun provideUserTokensEncricher( - notificationsFeatureToggles: NotificationsFeatureToggles, walletsRepository: WalletsRepository, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, dispatchers: CoroutineDispatcherProvider, ): UserTokensResponseAddressesEnricher { return UserTokensResponseAddressesEnricher( - notificationsFeatureToggles = notificationsFeatureToggles, walletsRepository = walletsRepository, multiNetworkStatusSupplier = multiNetworkStatusSupplier, dispatchers = dispatchers, diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt index faf985a7b2..aed68d658d 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt @@ -7,7 +7,6 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -23,7 +22,6 @@ import org.junit.Test class UserTokensResponseAddressesEnricherTest { - private lateinit var notificationsFeatureToggles: NotificationsFeatureToggles private lateinit var walletsRepository: WalletsRepository private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider() private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier @@ -31,12 +29,10 @@ class UserTokensResponseAddressesEnricherTest { @Before fun setup() { - notificationsFeatureToggles = mockk() walletsRepository = mockk() multiNetworkStatusSupplier = mockk() enricher = UserTokensResponseAddressesEnricher( - notificationsFeatureToggles = notificationsFeatureToggles, walletsRepository = walletsRepository, dispatchers = dispatchers, multiNetworkStatusSupplier = multiNetworkStatusSupplier, @@ -54,7 +50,6 @@ class UserTokensResponseAddressesEnricherTest { val userWalletId = UserWalletId("1234567890abcdef") val token = createToken() val response = createUserTokensResponse(tokens = listOf(token)) - every { notificationsFeatureToggles.isNotificationsEnabled } returns false // WHEN val result = enricher(userWalletId, response) @@ -70,7 +65,6 @@ class UserTokensResponseAddressesEnricherTest { val userWalletId = UserWalletId("1234567890abcdef") val token = createToken() val response = createUserTokensResponse(tokens = listOf(token)) - every { notificationsFeatureToggles.isNotificationsEnabled } returns true coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns false coEvery { multiNetworkStatusSupplier.invoke(any()) @@ -110,7 +104,6 @@ class UserTokensResponseAddressesEnricherTest { val response = createUserTokensResponse(tokens = listOf(token)) val addresses = listOf("0x123", "0x456") - every { notificationsFeatureToggles.isNotificationsEnabled } returns true coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true coEvery { multiNetworkStatusSupplier.invoke(any()) @@ -152,7 +145,6 @@ class UserTokensResponseAddressesEnricherTest { val token = createToken() val response = createUserTokensResponse(tokens = listOf(token)) - every { notificationsFeatureToggles.isNotificationsEnabled } returns true coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true coEvery { multiNetworkStatusSupplier.invoke(any()) diff --git a/domain/notifications/toggles/.gitignore b/domain/notifications/toggles/.gitignore deleted file mode 100644 index 42afabfd2a..0000000000 --- a/domain/notifications/toggles/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/domain/notifications/toggles/build.gradle.kts b/domain/notifications/toggles/build.gradle.kts deleted file mode 100644 index 7ff7fb7522..0000000000 --- a/domain/notifications/toggles/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -plugins { - alias(deps.plugins.kotlin.jvm) - id("configuration") -} \ No newline at end of file diff --git a/domain/notifications/toggles/src/main/java/com/tangem/domain/notifications/toggles/NotificationsFeatureToggles.kt b/domain/notifications/toggles/src/main/java/com/tangem/domain/notifications/toggles/NotificationsFeatureToggles.kt deleted file mode 100644 index 22bbec7d10..0000000000 --- a/domain/notifications/toggles/src/main/java/com/tangem/domain/notifications/toggles/NotificationsFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.domain.notifications.toggles - -interface NotificationsFeatureToggles { - val isNotificationsEnabled: Boolean -} \ No newline at end of file diff --git a/features/push-notifications/impl/build.gradle.kts b/features/push-notifications/impl/build.gradle.kts index 2f2c875648..e8a673832e 100644 --- a/features/push-notifications/impl/build.gradle.kts +++ b/features/push-notifications/impl/build.gradle.kts @@ -40,7 +40,6 @@ dependencies { /** Domain module */ implementation(projects.domain.settings) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.notifications) /** Feature modules */ diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt index 259b0bacf0..2862913418 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsBottomSheetComponent.kt @@ -1,8 +1,6 @@ package com.tangem.features.pushnotifications.impl import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel @@ -38,7 +36,6 @@ internal class DefaultPushNotificationsBottomSheetComponent @AssistedInject cons @Composable override fun BottomSheet() { - val state by model.state.collectAsState() val bottomSheetConfig = remember(key1 = this) { TangemBottomSheetConfig( isShown = true, @@ -55,7 +52,6 @@ internal class DefaultPushNotificationsBottomSheetComponent @AssistedInject cons onLaterClick = model::onLaterClick, onAllowPermission = model::onAllowPermission, onDenyPermission = model::onDenyPermission, - showNotificationsInfo = state.showInfoAboutNotifications, ) } } diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt index 458d977d4e..3eb2a6fdc9 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt @@ -2,8 +2,6 @@ package com.tangem.features.pushnotifications.impl import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.tangem.core.decompose.context.AppComponentContext @@ -28,15 +26,15 @@ internal class DefaultPushNotificationsComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val activity = LocalContext.current.findActivity() - val state by model.state.collectAsState() + BackHandler(onBack = { activity.finish() }) NavigationBar3ButtonsScrim() + PushNotificationsScreen( onAllowClick = model::onAllowClick, onLaterClick = model::onLaterClick, onAllowPermission = model::onAllowPermission, onDenyPermission = model::onDenyPermission, - showNotificationsInfo = state.showInfoAboutNotifications, ) } diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt index c76c250f14..bd3edb7000 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt @@ -9,16 +9,12 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION -import com.tangem.features.pushnotifications.impl.presentation.state.PushNotificationsUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,7 +28,6 @@ internal class PushNotificationsModel @Inject constructor( private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase, private val appRouter: AppRouter, private val analyticHandler: AnalyticsEventHandler, - private val notificationsFeatureToggles: NotificationsFeatureToggles, private val notificationsRepository: NotificationsRepository, ) : Model(), PushNotificationsClickIntents { @@ -43,32 +38,21 @@ internal class PushNotificationsModel @Inject constructor( AppRoute.PushNotification.Source.Onboarding -> AnalyticsParam.ScreensSources.Onboarding } - private val _state = MutableStateFlow( - PushNotificationsUM( - showInfoAboutNotifications = notificationsFeatureToggles.isNotificationsEnabled, - ), - ) - init { analyticHandler.send(PushNotificationAnalyticEvents.NotificationsScreenOpened(source)) } - val state = _state.asStateFlow() - override fun onAllowClick() { - if (notificationsFeatureToggles.isNotificationsEnabled) { - modelScope.launch { - notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true) - } + modelScope.launch { + notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true) } + analyticHandler.send(PushNotificationAnalyticEvents.ButtonAllow(source)) } override fun onLaterClick() { - if (notificationsFeatureToggles.isNotificationsEnabled) { - modelScope.launch { - notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false) - } + modelScope.launch { + notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false) } analyticHandler.send(PushNotificationAnalyticEvents.ButtonLater(source)) modelScope.launch { diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/state/PushNotificationsUM.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/state/PushNotificationsUM.kt deleted file mode 100644 index a7161b630b..0000000000 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/state/PushNotificationsUM.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.pushnotifications.impl.presentation.state - -data class PushNotificationsUM( - val showInfoAboutNotifications: Boolean = false, -) \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt index fa78628411..07090215d9 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsBottomSheet.kt @@ -44,7 +44,6 @@ internal fun PushNotificationsContent( onLaterClick: () -> Unit, onAllowPermission: () -> Unit, onDenyPermission: () -> Unit, - showNotificationsInfo: Boolean, ) { val requestPushPermission = requestPermission( onAllow = onAllowPermission, @@ -65,18 +64,11 @@ internal fun PushNotificationsContent( R.drawable.ic_storefront_24, resourceReference(R.string.user_push_notification_agreement_argument_two), ), - ).let { baseItems -> - if (showNotificationsInfo) { - baseItems.add( - ShowcaseItemModel( - R.drawable.ic_notifications_24, - resourceReference(R.string.user_push_notification_agreement_argument_three), - ), - ) - } else { - baseItems - } - }, + ShowcaseItemModel( + R.drawable.ic_notifications_24, + resourceReference(R.string.user_push_notification_agreement_argument_three), + ), + ), modifier = Modifier.padding(top = TangemTheme.dimens.spacing40), ) SpacerH28() @@ -111,7 +103,6 @@ private fun Preview_PushNotificationsBottomSheet() { onLaterClick = {}, onAllowPermission = {}, onDenyPermission = {}, - showNotificationsInfo = true, ) } } diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt index 4750463f50..81ec2b2743 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt @@ -18,7 +18,6 @@ internal fun PushNotificationsScreen( onLaterClick: () -> Unit, onAllowPermission: () -> Unit, onDenyPermission: () -> Unit, - showNotificationsInfo: Boolean, ) { val requestPushPermission = requestPermission( onAllow = onAllowPermission, @@ -38,18 +37,11 @@ internal fun PushNotificationsScreen( R.drawable.ic_storefront_24, resourceReference(R.string.user_push_notification_agreement_argument_two), ), - ).let { baseItems -> - if (showNotificationsInfo) { - baseItems.add( - ShowcaseItemModel( - R.drawable.ic_notifications_24, - resourceReference(R.string.user_push_notification_agreement_argument_three), - ), - ) - } else { - baseItems - } - }, + ShowcaseItemModel( + R.drawable.ic_notifications_24, + resourceReference(R.string.user_push_notification_agreement_argument_three), + ), + ), primaryButton = ShowcaseButtonModel( buttonText = resourceReference(R.string.common_allow), onClick = { diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index 4ab6bc0a40..3bb8904189 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -41,7 +41,6 @@ dependencies { implementation(projects.domain.demo) implementation(projects.domain.nft) implementation(projects.domain.settings) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.notifications.models) implementation(projects.domain.notifications) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 5eba834c7e..fac9ee92d6 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -32,7 +32,6 @@ import com.tangem.domain.nft.EnableWalletNFTUseCase import com.tangem.domain.nft.GetWalletNFTEnabledUseCase import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.repositories.PermissionRepository import com.tangem.domain.wallets.usecase.* import com.tangem.feature.walletsettings.analytics.Settings @@ -73,7 +72,6 @@ internal class WalletSettingsModel @Inject constructor( getWalletNFTEnabledUseCase: GetWalletNFTEnabledUseCase, private val enableWalletNFTUseCase: EnableWalletNFTUseCase, private val disableWalletNFTUseCase: DisableWalletNFTUseCase, - private val notificationsToggles: NotificationsFeatureToggles, getWalletNotificationsEnabledUseCase: GetWalletNotificationsEnabledUseCase, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, private val settingsManager: SettingsManager, @@ -130,8 +128,7 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Hot -> wallet.backedUp is UserWallet.Cold -> true } - val isNeedShowNotifications = notificationsToggles.isNotificationsEnabled && - !getIsHuaweiDeviceWithoutGoogleServicesUseCase() + val isNeedShowNotifications = !getIsHuaweiDeviceWithoutGoogleServicesUseCase() state.update { value -> value.copy( items = buildItems( diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 1df9de0f95..daafa8122d 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -95,7 +95,6 @@ dependencies { implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.notifications) - implementation(projects.domain.notifications.toggles) implementation(projects.domain.transaction) /** Feature Apis */ diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 7e4574513d..b72ac8cf5c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -17,7 +17,6 @@ import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.wallets.usecase.* @@ -79,7 +78,6 @@ internal class WalletModel @Inject constructor( private val notificationsRepository: NotificationsRepository, private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, - private val notificationsFeatureToggles: NotificationsFeatureToggles, private val shouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, @@ -575,7 +573,6 @@ internal class WalletModel @Inject constructor( } private fun enableNotificationsIfNeeded() { - if (!notificationsFeatureToggles.isNotificationsEnabled) return modelScope.launch { val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() if (isUserAllowToEnableNotifications) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 1511ce8a21..19c6d1c875 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -341,7 +341,6 @@ include(":domain:blockaid") include(":domain:blockaid:models") include(":domain:notifications") include(":domain:notifications:models") -include(":domain:notifications:toggles") include(":domain:express") include(":domain:express:models") include(":domain:swap") From 2471b9c1a8fb8227c3226b88a87b5c080ff5d9c1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Sep 2025 18:33:57 +0400 Subject: [PATCH 051/132] Updated on 2026-08-14 --- data/common/build.gradle.kts | 2 + .../UserTokensResponseAccountIdEnricher.kt | 94 +++++++++++ .../data/common/currency/UserTokensSaver.kt | 93 +++++++---- .../tangem/data/common/di/DataCommonModule.kt | 7 +- ...UserTokensResponseAccountIdEnricherTest.kt | 151 ++++++++++++++++++ .../common/currency/UserTokensSaverTest.kt | 7 +- 6 files changed, 317 insertions(+), 37 deletions(-) create mode 100644 data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt create mode 100644 data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index ece3e5297a..9994bc2f46 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.core.utils) /* Domain */ + implementation(projects.domain.account) implementation(projects.domain.demo) implementation(projects.domain.legacy) implementation(projects.domain.card) @@ -31,6 +32,7 @@ dependencies { /* Libs - SDK */ implementation(tangemDeps.blockchain) implementation(tangemDeps.card.core) + implementation(projects.libs.crypto) implementation(projects.libs.blockchainSdk) /* DI */ diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt new file mode 100644 index 0000000000..b52d3c3f86 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt @@ -0,0 +1,94 @@ +package com.tangem.data.common.currency + +import arrow.core.getOrElse +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer +import timber.log.Timber + +/** + * Enriches the [UserTokensResponse] with accountId values for tokens + * +[REDACTED_AUTHOR] + */ +object UserTokensResponseAccountIdEnricher { + + /** + * Enriches the tokens in the given [UserTokensResponse] with accountId values + * + * @param userWalletId the ID of the user wallet + * @param response the [UserTokensResponse] containing tokens to be enriched + */ + operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse { + val hasUnassignedTokens = response.tokens.any { it.accountId == null } + if (!hasUnassignedTokens) return response + + val enrichedTokens = response.tokens + .filter { it.accountId == null } + .groupByAccountIndex() + .mapKeysToAccountId(userWalletId) + .mapToEnrichedTokens() + + if (enrichedTokens.isEmpty()) return response + + return response.copy( + tokens = response.tokens.map { token -> + val enrichedToken = enrichedTokens.find { it == token } + enrichedToken ?: token + }, + ) + } + + private fun List.groupByAccountIndex(): Map> { + return this + .groupBy { savedToken -> + val derivationPathValue = savedToken.derivationPath + if (derivationPathValue == null) { + Timber.e("Token $savedToken has no derivation path") + return@groupBy null + } + + val blockchain = Blockchain.fromNetworkId(networkId = savedToken.networkId) + if (blockchain == null) { + Timber.e("Token $savedToken has unknown networkId") + return@groupBy null + } + + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + val accountIndex = accountNodeRecognizer.recognize(derivationPathValue) + if (accountIndex == null) { + Timber.e("Token $savedToken has unrecognized derivation path") + return@groupBy null + } + + accountIndex + } + } + + private fun Map>.mapKeysToAccountId( + userWalletId: UserWalletId, + ): Map> { + return mapKeys { (accountIndex, _) -> + if (accountIndex == null) return@mapKeys null + + val derivationIndex = DerivationIndex.invoke(value = accountIndex.toInt()).getOrElse { + Timber.e("Failed to parse derivation index from account index: $accountIndex") + return@mapKeys null + } + + AccountId.forCryptoPortfolio(userWalletId, derivationIndex) + } + } + + private fun Map>.mapToEnrichedTokens(): List { + return flatMap { (accountId, tokens) -> + if (accountId == null) return@flatMap emptyList() + + tokens.map { it.copy(accountId = accountId.value) } + } + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index 95c9010707..e0bfb846d8 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -5,60 +5,85 @@ import com.tangem.data.common.tokens.UserTokensBackwardCompatibility import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext -import timber.log.Timber class UserTokensSaver( private val tangemTechApi: TangemTechApi, private val userTokensResponseStore: UserTokensResponseStore, private val dispatchers: CoroutineDispatcherProvider, - private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher, + private val addressesEnricher: UserTokensResponseAddressesEnricher, + private val accountsFeatureToggles: AccountsFeatureToggles, ) { private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility() - suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) = - withContext(dispatchers.io) { - val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response) - val enrichedUserTokensResponse = if (useEnricher) { - userTokensResponseAddressesEnricher( - userWalletId = userWalletId, - response = compatibleUserTokensResponse, - ) - } else { - compatibleUserTokensResponse - } - - userTokensResponseStore.store(userWalletId = userWalletId, response = enrichedUserTokensResponse) - } - suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) { - val enrichedUserTokensResponse = userTokensResponseAddressesEnricher( - userWalletId = userWalletId, - response = response, - ) - store(userWalletId, enrichedUserTokensResponse, false) - push(userWalletId, enrichedUserTokensResponse, false) + withContext(dispatchers.default) { + val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = true) + + store(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false) + push(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false) + } } + suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) = + withContext(dispatchers.default) { + val updatedResponse = response + .applyCompatibility() + .enrichIf(userWalletId = userWalletId, condition = useEnricher) + + userTokensResponseStore.store(userWalletId = userWalletId, response = updatedResponse) + } + suspend fun push( userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true, onFailSend: () -> Unit = {}, - ) = withContext(dispatchers.io) { - val enrichedUserTokensResponse = if (useEnricher) { - userTokensResponseAddressesEnricher( - userWalletId = userWalletId, - response = response, + ) { + withContext(dispatchers.default) { + val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher) + + safeApiCall( + call = { + withContext(dispatchers.io) { + tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = enrichedResponse) + .bind() + } + }, + onError = { onFailSend() }, ) - } else { - response - } - safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedUserTokensResponse).bind() }) { - Timber.e(it, "Unable to push user tokens for: ${userWalletId.stringValue}") - onFailSend() } } + + private fun UserTokensResponse.applyCompatibility(): UserTokensResponse { + return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this) + } + + private suspend fun UserTokensResponse.enrichIf( + userWalletId: UserWalletId, + condition: Boolean, + ): UserTokensResponse { + if (!condition) return this + + return this + .enrichByAddress(userWalletId = userWalletId) + .let { + if (accountsFeatureToggles.isFeatureEnabled) { + it.enrichByAccountId(userWalletId = userWalletId) + } else { + it + } + } + } + + private suspend fun UserTokensResponse.enrichByAddress(userWalletId: UserWalletId): UserTokensResponse { + return addressesEnricher(userWalletId = userWalletId, response = this) + } + + private fun UserTokensResponse.enrichByAccountId(userWalletId: UserWalletId): UserTokensResponse { + return UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, response = this) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 85260e75da..9b18e1b8fd 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -7,6 +7,7 @@ import com.tangem.data.common.quote.QuotesFetcher import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.wallets.repository.WalletsRepository @@ -58,13 +59,15 @@ internal object DataCommonModule { tangemTechApi: TangemTechApi, userTokensResponseStore: UserTokensResponseStore, dispatchers: CoroutineDispatcherProvider, - enricher: UserTokensResponseAddressesEnricher, + addressesEnricher: UserTokensResponseAddressesEnricher, + accountsFeatureToggles: AccountsFeatureToggles, ): UserTokensSaver { return UserTokensSaver( tangemTechApi = tangemTechApi, userTokensResponseStore = userTokensResponseStore, dispatchers = dispatchers, - userTokensResponseAddressesEnricher = enricher, + addressesEnricher = addressesEnricher, + accountsFeatureToggles = accountsFeatureToggles, ) } diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt new file mode 100644 index 0000000000..bd7c253d03 --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt @@ -0,0 +1,151 @@ +package com.tangem.data.common.currency + +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class UserTokensResponseAccountIdEnricherTest { + + private val userWalletId = UserWalletId("011") + private val mockCryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val userTokensResponseFactory = UserTokensResponseFactory() + + @Test + fun `enriches tokens with missing account ids`() { + // Arrange + val response = mockCryptoCurrencyFactory.ethereumAndStellar + .mapIndexed { index, currency -> + currency.toResponseToken( + accountId = null, + derivationPath = "m/44'/60'/$index'/0/0", + ) + } + .toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = response.tokens + .mapIndexed { index, currency -> + currency.enrichWithAccountId(accountIndex = index) + } + .toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `does not modify tokens with existing account ids`() { + // Arrange + val response = mockCryptoCurrencyFactory.ethereumAndStellar + .mapIndexed { index, currency -> + currency.toResponseToken(derivationPath = "m/44'/60'/$index'/0/0") + .enrichWithAccountId(accountIndex = index) + } + .toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = response + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `skips tokens with invalid derivation paths`() { + // Arrange + val validDerivationPath = "m/44'/60'/0'/0/0" + val invalidDerivationPath = "invalid/path" + + val tokenWithInvalidPath = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, + derivationPath = invalidDerivationPath, + ) + + val tokenWithValidPath = mockCryptoCurrencyFactory.stellar.toResponseToken( + accountId = null, + derivationPath = validDerivationPath, + ) + + val response = listOf(tokenWithInvalidPath, tokenWithValidPath).toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = listOf( + tokenWithInvalidPath, + tokenWithValidPath.enrichWithAccountId(accountIndex = 0), + ).toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `skips tokens with unknown network id`() { + // Arrange + val unknownNetworkId = "unknown" + val validNetworkId = mockCryptoCurrencyFactory.ethereum.network.rawId + + val tokenWithUnknownNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + networkId = unknownNetworkId, + derivationPath = "m/44'/60'/0'/0/0", + accountId = null, + ) + + val tokenWithValidNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, + networkId = validNetworkId, + derivationPath = "m/44'/60'/0'/0/0", + ) + + val response = listOf(tokenWithUnknownNetworkId, tokenWithValidNetworkId).toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = listOf( + tokenWithUnknownNetworkId, + tokenWithValidNetworkId.enrichWithAccountId(accountIndex = 0), + ).toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun CryptoCurrency.toResponseToken( + accountId: AccountId? = null, + networkId: String? = null, + derivationPath: String, + ): UserTokensResponse.Token { + return userTokensResponseFactory.createResponseToken(this).copy( + networkId = networkId ?: network.rawId, + derivationPath = derivationPath, + accountId = accountId?.value, + ) + } + + private fun List.toResponse(): UserTokensResponse { + return UserTokensResponse( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + tokens = this, + ) + } + + private fun UserTokensResponse.Token.enrichWithAccountId(accountIndex: Int): UserTokensResponse.Token { + val derivationIndex = DerivationIndex(value = accountIndex).getOrNull()!! + val accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex) + + return copy(accountId = accountId.value) + } +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index 6b4a086180..66e4d28c01 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -5,6 +5,7 @@ import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -19,12 +20,16 @@ class UserTokensSaverTest { private val tangemTechApi: TangemTechApi = mockk() private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true) private val enricher: UserTokensResponseAddressesEnricher = mockk() + private val accountsFeatureToggles = mockk { + every { this@mockk.isFeatureEnabled } returns true + } private val userTokensSaver: UserTokensSaver = UserTokensSaver( tangemTechApi = tangemTechApi, userTokensResponseStore = userTokensResponseStore, - userTokensResponseAddressesEnricher = enricher, dispatchers = TestingCoroutineDispatcherProvider(), + addressesEnricher = enricher, + accountsFeatureToggles = accountsFeatureToggles, ) @BeforeEach From 941863f79bedac4ebfa5f1a1bdc7733bde41d219 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Sep 2025 17:10:01 +0500 Subject: [PATCH 052/132] Updated on 2026-08-14 --- .../main/java/com/tangem/tap/MainActivity.kt | 7 +- .../tangem/tap/routing/utils/ChildFactory.kt | 2 +- .../com/tangem/common/routing/AppRoute.kt | 2 +- .../transactions/TxHistoryGroupTitle.kt | 2 +- .../tangempay/details/api/build.gradle.kts | 3 + .../components/TangemPayDetailsComponent.kt | 4 +- .../tangempay/details/impl/build.gradle.kts | 6 + .../DefaultTangemPayDetailsComponent.kt | 8 +- .../DefaultTangemPayTxHistoryComponent.kt | 26 +++ .../PreviewTangemPayTxHistoryComponent.kt | 174 ++++++++++++++++++ .../txHistory/TangemPayTxHistoryComponent.kt | 11 ++ .../tangempay/di/TangemPayModelModule.kt | 8 +- .../model/TangemPayTxHistoryModel.kt | 62 +++++++ .../tangempay/ui/TangemPayDetailsScreen.kt | 24 ++- .../features/txhistory/entity/TxHistoryUM.kt | 5 +- .../features/txhistory/ui/TxHistoryContent.kt | 31 +--- .../component/DefaultTxHistoryComponent.kt | 1 - .../extension/BaseExtensionConfigurations.kt | 1 + 18 files changed, 334 insertions(+), 43 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt rename features/txhistory/{impl => api}/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt (83%) diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 9cb6b6cd62..659aaec327 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -525,8 +525,11 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { // Workaround to navigate to TangemPayDetails screen. Will be deleted in next PRs if (tangemPayFeatureToggles.isTangemPayEnabled) { - store.dispatchNavigationAction { - replaceAll(AppRoute.TangemPayDetails) + lifecycleScope.launch { + val selectedUserWalledId = userWalletsListRepository.selectedUserWalletSync()?.walletId + store.dispatchNavigationAction { + replaceAll(AppRoute.TangemPayDetails(requireNotNull(selectedUserWalledId))) + } } } else if (userWalletsListManager.isLockable && userWalletsListManager.hasUserWallets) { store.dispatchNavigationAction { 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 708e4bc745..5276185422 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 @@ -591,7 +591,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.TangemPayDetails -> { createComponentChild( context = context, - params = TangemPayDetailsComponent.Params(), + params = TangemPayDetailsComponent.Params(userWalletId = route.userWalletId), componentFactory = tangemPayDetailsComponentFactory, ) } 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 d2204b5cd1..7e98053f5f 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 @@ -369,5 +369,5 @@ sealed class AppRoute(val path: String) : Route { ) : AppRoute(path = "/archived_account/${userWalletId.stringValue}") @Serializable - data object TangemPayDetails : AppRoute(path = "/tangem_pay_details") + data class TangemPayDetails(val userWalletId: UserWalletId) : AppRoute(path = "/tangem_pay_details") } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt index e0298ceaed..a34ed1e6c2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryGroupTitle.kt @@ -23,7 +23,7 @@ import java.util.UUID * @param modifier modifier */ @Composable -internal fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) { +fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) { Box( modifier = modifier .background(TangemTheme.colors.background.primary) diff --git a/features/tangempay/details/api/build.gradle.kts b/features/tangempay/details/api/build.gradle.kts index 77acdd5c22..7d64c9e77f 100644 --- a/features/tangempay/details/api/build.gradle.kts +++ b/features/tangempay/details/api/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) + /** Domain */ + implementation(projects.domain.models) + /** Compose */ implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 64e11cecbe..bd9326f7e9 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -2,9 +2,9 @@ package com.tangem.features.tangempay.components import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId interface TangemPayDetailsComponent : ComposableContentComponent { - @Suppress("EmptyDefaultConstructor") // Will add params in Next PRs - class Params() + data class Params(val userWalletId: UserWalletId) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index ae6c79993c..84f577edf1 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -19,6 +19,12 @@ dependencies { /** Features api */ implementation(projects.features.tangempay.details.api) + implementation(projects.features.txhistory.api) + + /** Domain */ + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.models) /** Compose */ implementation(deps.compose.foundation) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt index 3fe44cd539..cd5e1f5b79 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt @@ -5,8 +5,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.ui.TangemPayDetailsScreen import dagger.assisted.Assisted @@ -19,12 +21,16 @@ internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( ) : AppComponentContext by appComponentContext, TangemPayDetailsComponent { private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( + appComponentContext = child("txHistoryComponent"), + params = DefaultTangemPayTxHistoryComponent.Params(userWalletId = params.userWalletId), + ) @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() NavigationBar3ButtonsScrim() - TangemPayDetailsScreen(state = state, modifier = modifier) + TangemPayDetailsScreen(state = state, txHistoryComponent = txHistoryComponent, modifier = modifier) } @AssistedFactory diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt new file mode 100644 index 0000000000..c0ccc0a291 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.tangempay.model.TangemPayTxHistoryModel +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.features.txhistory.ui.txHistoryItems +import kotlinx.coroutines.flow.StateFlow + +internal class DefaultTangemPayTxHistoryComponent( + appComponentContext: AppComponentContext, + params: Params, +) : AppComponentContext by appComponentContext, TangemPayTxHistoryComponent { + + private val model: TangemPayTxHistoryModel = getOrCreateModel(params = params) + override val state: StateFlow = model.uiState + + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) { + txHistoryItems(listState, state) + } + + data class Params(val userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt new file mode 100644 index 0000000000..61d078d853 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt @@ -0,0 +1,174 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.features.txhistory.ui.txHistoryItems +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TxHistoryUM) : TangemPayTxHistoryComponent { + override val state: StateFlow = MutableStateFlow(txHistoryUM) + + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) { + txHistoryItems(listState, state) + } + + companion object { + val loadingUM = TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = {}) + val emptyUM = TxHistoryUM.Empty(isBalanceHidden = true, onExploreClick = {}) + val contentUM = TxHistoryUM.Content( + isBalanceHidden = false, + loadMore = { false }, + items = persistentListOf( + TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = {}), + TxHistoryUM.TxHistoryItemUM.GroupTitle(title = "Today", itemKey = "Today"), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "16:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "12:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.GroupTitle(title = "Yesterday", itemKey = "Yesterday"), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "21:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "10:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "19:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "18:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "17:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "16:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-4.99 USD", + time = "15:41", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Starbucks"), + subtitle = stringReference("Food&Drinks"), + timestamp = 3464, + ), + ), + TxHistoryUM.TxHistoryItemUM.Transaction( + state = TransactionState.Content( + txHash = "signiferumque", + amount = "-126.20 USD", + time = "14:04", + status = TransactionState.Content.Status.Confirmed, + direction = TransactionState.Content.Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_arrow_up_24, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + timestamp = 3465, + ), + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryComponent.kt new file mode 100644 index 0000000000..3b538ef7fd --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryComponent.kt @@ -0,0 +1,11 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.features.txhistory.entity.TxHistoryUM +import kotlinx.coroutines.flow.StateFlow + +internal interface TangemPayTxHistoryComponent { + val state: StateFlow + fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index a9bee0452a..ef72f17c45 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.tangempay.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.model.TangemPayTxHistoryModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -16,5 +17,10 @@ internal interface TangemPayModelModule { @Binds @IntoMap @ClassKey(TangemPayDetailsModel::class) - fun bindWcConnectionsModel(model: TangemPayDetailsModel): Model + fun bindTangemPayDetailsModel(model: TangemPayDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayTxHistoryModel::class) + fun bindTangemPayTxHistoryModel(model: TangemPayTxHistoryModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt new file mode 100644 index 0000000000..e8532b5dbe --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt @@ -0,0 +1,62 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayTxHistoryModel @Inject constructor( + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: DefaultTangemPayTxHistoryComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + init { + handleBalanceHiding() + subscribeToUiItemChanges() + } + + @Suppress("MagicNumber") + private fun subscribeToUiItemChanges() { + modelScope.launch { + Timber.d("subscribeToUiItemChanges: ${params.userWalletId}") + delay(2000) + uiState.update { PreviewTangemPayTxHistoryComponent.contentUM } + } + } + + private fun handleBalanceHiding() { + getBalanceHidingSettingsUseCase() + .onEach { uiState.update { state -> state.copySealed(isBalanceHidden = it.isBalanceHidden) } } + .launchIn(modelScope) + } + + private fun onExploreClick() { + Timber.d("onExploreClick: open explorer") + } + + private fun getInitialState(): TxHistoryUM { + return TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::onExploreClick) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 3318cbe1b5..f68617ebd5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -19,11 +19,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.buttons.HorizontalActionChips import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig @@ -37,6 +39,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags +import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState @@ -46,7 +50,11 @@ import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @Composable -internal fun TangemPayDetailsScreen(state: TangemPayDetailsUM, modifier: Modifier = Modifier) { +internal fun TangemPayDetailsScreen( + state: TangemPayDetailsUM, + txHistoryComponent: TangemPayTxHistoryComponent, + modifier: Modifier = Modifier, +) { Scaffold( modifier = modifier, topBar = { TangemPayDetailsTopAppBar(config = state.topBarConfig) }, @@ -55,6 +63,7 @@ internal fun TangemPayDetailsScreen(state: TangemPayDetailsUM, modifier: Modifie ) { scaffoldPaddings -> val listState = rememberLazyListState() val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() TangemPullToRefreshContainer( config = state.pullToRefreshConfig, @@ -91,6 +100,8 @@ internal fun TangemPayDetailsScreen(state: TangemPayDetailsUM, modifier: Modifie ) }, ) + + with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } } } } @@ -317,14 +328,19 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi ) } -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(device = Devices.PIXEL_7_PRO, group = "day") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO, group = "night") @Composable private fun TangemPayDetailsScreenPreview( @PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM, ) { TangemThemePreview { - TangemPayDetailsScreen(state = state) + TangemPayDetailsScreen( + state = state, + txHistoryComponent = PreviewTangemPayTxHistoryComponent( + txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, + ), + ) } } diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt index 9a9960fc8e..ad33da1dc0 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.txhistory.entity import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.components.transactions.state.TxHistoryState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -81,7 +82,9 @@ sealed interface TxHistoryUM { data class GroupTitle( val title: String, val itemKey: String, - ) : TxHistoryItemUM + ) : TxHistoryItemUM { + val legacyGroupTitle = TxHistoryState.TxHistoryItemState.GroupTitle(title = title, itemKey = itemKey) + } /** * Transaction item diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt similarity index 83% rename from features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt rename to features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt index 9525d0ede2..aafd528437 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt @@ -1,20 +1,16 @@ package com.tangem.features.txhistory.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.transactions.PendingTxsBlock import com.tangem.core.ui.components.transactions.Transaction +import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle import com.tangem.core.ui.components.transactions.TxHistoryTitle import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState @@ -25,7 +21,7 @@ import com.tangem.features.txhistory.entity.TxHistoryUM private const val LOAD_ITEMS_BUFFER = 20 -internal fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) { +fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) { when (state) { is TxHistoryUM.Content -> contentItems(listState, state) is TxHistoryUM.Empty -> nonContentItem(state = EmptyTransactionsBlockState.Empty(state.onExploreClick)) @@ -127,7 +123,7 @@ internal fun TxHistoryListItem( ) { when (state) { is TxHistoryUM.TxHistoryItemUM.GroupTitle -> { - TxHistoryGroupTitle(config = state, modifier = modifier) + TxHistoryGroupTitle(config = state.legacyGroupTitle, modifier = modifier) } is TxHistoryUM.TxHistoryItemUM.Title -> { TxHistoryTitle(onExploreClick = state.onExploreClick, modifier = modifier) @@ -140,25 +136,4 @@ internal fun TxHistoryListItem( ) } } -} - -@Composable -private fun TxHistoryGroupTitle(config: TxHistoryUM.TxHistoryItemUM.GroupTitle, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .background(TangemTheme.colors.background.primary) - .padding( - vertical = TangemTheme.dimens.spacing8, - horizontal = TangemTheme.dimens.spacing12, - ) - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size24), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = config.title, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.body2, - ) - } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt index 7ec4d11055..907f834388 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt @@ -2,7 +2,6 @@ package com.tangem.features.txhistory.component import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.* import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.txhistory.entity.TxHistoryUM diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index c19607fae8..7011f2d423 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -29,6 +29,7 @@ internal fun BaseExtension.configureCompose(project: Project) { contains(Regex(pattern = ":app\$")) || // TODO: [REDACTED_JIRA] contains(Regex(pattern = ":features:markets:api\$")) || // provides Composable function contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function + contains(Regex(pattern = ":features:txhistory:api\$")) || // provides Composable function contains(Regex(pattern = ":impl\$")) } From 411a5915092c46ce467b73c9ccb12b1c0fac91cd Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 23:22:34 +0500 Subject: [PATCH 053/132] Updated on 2026-08-14 --- .../tap/di/domain/WalletsDomainModule.kt | 31 +++++- .../tap/features/hot/TangemHotSDKProxy.kt | 7 ++ .../tangem/tap/routing/utils/ChildFactory.kt | 11 ++ .../com/tangem/common/routing/AppRoute.kt | 5 + .../wallets/hot/DefaultHotWalletAccessor.kt | 29 +++++ .../domain/wallets/hot/HotWalletAccessor.kt | 6 + .../ClearHotWalletContextualUnlockUseCase.kt | 17 +++ .../usecase/ExportSeedPhraseUseCase.kt | 17 +++ .../UnlockHotWalletContextualUseCase.kt | 18 +++ .../ui/CreateWalletSelectionContent.kt | 2 +- .../features/hotwallet/ViewPhraseComponent.kt | 14 +++ .../viewphrase/DefaultViewPhraseComponent.kt | 38 +++++++ .../viewphrase/di/ViewPhraseModule.kt | 25 +++++ .../viewphrase/entity/ViewPhraseUM.kt | 10 ++ .../viewphrase/model/ViewPhraseModel.kt | 76 +++++++++++++ .../viewphrase/ui/ViewPhraseContent.kt | 103 ++++++++++++++++++ .../walletbackup/model/WalletBackupModel.kt | 85 ++++++++------- gradle/tangem_dependencies.toml | 2 +- 18 files changed, 453 insertions(+), 43 deletions(-) create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ExportSeedPhraseUseCase.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockHotWalletContextualUseCase.kt create mode 100644 features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ViewPhraseComponent.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/di/ViewPhraseModule.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/entity/ViewPhraseUM.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/ui/ViewPhraseContent.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index 911eededb2..0f1f035a13 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.domain +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.WalletAddressServiceRepository @@ -9,8 +10,8 @@ import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.delegate.DefaultUserWalletsSyncDelegate import com.tangem.domain.wallets.delegate.UserWalletsSyncDelegate +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.* @@ -372,4 +373,32 @@ internal object WalletsDomainModule { walletsRepository = walletsRepository, ) } + + @Provides + @Singleton + fun providesUnlockHotWalletContextualUseCase( + hotWalletAccessor: HotWalletAccessor, + ): UnlockHotWalletContextualUseCase { + return UnlockHotWalletContextualUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + + @Provides + @Singleton + fun providesClearHotWalletContextualUnlockUseCase( + hotWalletAccessor: HotWalletAccessor, + ): ClearHotWalletContextualUnlockUseCase { + return ClearHotWalletContextualUnlockUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + + @Provides + @Singleton + fun providesExportSeedPhraseUseCase(hotWalletAccessor: HotWalletAccessor): ExportSeedPhraseUseCase { + return ExportSeedPhraseUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt b/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt index af00ff8af8..237bc5ebd3 100644 --- a/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt +++ b/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt @@ -32,6 +32,13 @@ class TangemHotSDKProxy @Inject constructor() : TangemHotSdk { override suspend fun exportBackup(unlockHotWallet: UnlockHotWallet): ByteArray = callSdk { exportBackup(unlockHotWallet) } + override suspend fun clearUnlockContext(hotWalletId: HotWalletId) { + callSdk { clearUnlockContext(hotWalletId) } + } + + override suspend fun getContextUnlock(unlockHotWallet: UnlockHotWallet): UnlockHotWallet = + callSdk { getContextUnlock(unlockHotWallet) } + override suspend fun delete(id: HotWalletId) = callSdk { delete(id) } override suspend fun changeAuth(unlockHotWallet: UnlockHotWallet, auth: HotAuth): HotWalletId = 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 5276185422..9525c29fa0 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 @@ -23,6 +23,7 @@ import com.tangem.features.hotwallet.CreateWalletBackupComponent import com.tangem.features.hotwallet.UpdateAccessCodeComponent import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.hotwallet.WalletBackupComponent +import com.tangem.features.hotwallet.ViewPhraseComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensSource @@ -109,6 +110,7 @@ internal class ChildFactory @Inject constructor( private val walletActivationComponentFactory: WalletActivationComponent.Factory, private val createWalletBackupComponentFactory: CreateWalletBackupComponent.Factory, private val updateAccessCodeComponentFactory: UpdateAccessCodeComponent.Factory, + private val viewPhraseComponentFactory: ViewPhraseComponent.Factory, private val sendWithSwapComponentFactory: SendWithSwapComponent.Factory, private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory, @@ -532,6 +534,15 @@ internal class ChildFactory @Inject constructor( componentFactory = updateAccessCodeComponentFactory, ) } + is AppRoute.ViewPhrase -> { + createComponentChild( + context = context, + params = ViewPhraseComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = viewPhraseComponentFactory, + ) + } is AppRoute.SendEntryPoint -> { createComponentChild( context = context, 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 7e98053f5f..06cede4e5a 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 @@ -334,6 +334,11 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, ) : AppRoute(path = "/update_access_code/${userWalletId.stringValue}") + @Serializable + data class ViewPhrase( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/view_seed_phrase/${userWalletId.stringValue}") + @Serializable data class SendEntryPoint( val userWalletId: UserWalletId, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index 796bc8d3ba..70273f2684 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -9,15 +9,26 @@ import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.exception.WrongPasswordException import com.tangem.hot.sdk.model.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import kotlin.collections.set class DefaultHotWalletAccessor @Inject constructor( private val tangemHotSdk: TangemHotSdk, private val userWalletsListRepository: UserWalletsListRepository, private val hotWalletPasswordRequester: HotWalletPasswordRequester, private val walletsRepository: WalletsRepository, + dispatchers: CoroutineDispatcherProvider, ) : HotWalletAccessor { + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + private var contextualUnlockHotWallet: ConcurrentHashMap = ConcurrentHashMap() + override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = hotSdkRequest(hotWalletId) { unlock -> tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign) @@ -30,6 +41,24 @@ class DefaultHotWalletAccessor @Inject constructor( tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request) } + override suspend fun exportSeedPhrase(hotWalletId: HotWalletId): SeedPhrasePrivateInfo { + val unlockHotWallet = contextualUnlockHotWallet[hotWalletId] ?: hotSdkRequest(hotWalletId) { it } + return tangemHotSdk.exportMnemonic(unlockHotWallet = unlockHotWallet) + } + + override suspend fun unlockContextual(hotWalletId: HotWalletId): UnlockHotWallet = hotSdkRequest(hotWalletId) { + tangemHotSdk.getContextUnlock(it).also { unlockHotWallet -> + contextualUnlockHotWallet[hotWalletId] = unlockHotWallet + } + } + + override fun clearContextualUnlock(hotWalletId: HotWalletId) { + contextualUnlockHotWallet[hotWalletId] = null + scope.launch { + tangemHotSdk.clearUnlockContext(hotWalletId) + } + } + private suspend fun hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T { val isAccessCodeRequired = walletsRepository.requireAccessCode() diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt index 18d9d792fe..f0297ced18 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt @@ -7,4 +7,10 @@ interface HotWalletAccessor { suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse + + suspend fun exportSeedPhrase(hotWalletId: HotWalletId): SeedPhrasePrivateInfo + + suspend fun unlockContextual(hotWalletId: HotWalletId): UnlockHotWallet + + fun clearContextualUnlock(hotWalletId: HotWalletId) } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt new file mode 100644 index 0000000000..be1a030837 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt @@ -0,0 +1,17 @@ + +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId + +class ClearHotWalletContextualUnlockUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.clearContextualUnlock(hotWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ExportSeedPhraseUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ExportSeedPhraseUseCase.kt new file mode 100644 index 0000000000..465a2560c3 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ExportSeedPhraseUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.hot.sdk.model.SeedPhrasePrivateInfo + +class ExportSeedPhraseUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + suspend operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.exportSeedPhrase(hotWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockHotWalletContextualUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockHotWalletContextualUseCase.kt new file mode 100644 index 0000000000..982821ce5b --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockHotWalletContextualUseCase.kt @@ -0,0 +1,18 @@ + +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.hot.sdk.model.UnlockHotWallet + +class UnlockHotWalletContextualUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + suspend operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.unlockContextual(hotWalletId) + } + } +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt index 4106c3d8c6..abae1d96ad 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt @@ -149,7 +149,7 @@ private fun WalletBlock( .padding(top = 8.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) .background( - color = TangemTheme.colors.background.secondary, + color = TangemTheme.colors.field.primary, shape = TangemTheme.shapes.roundedCornersXMedium, ) .clickable(onClick = onClick) diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ViewPhraseComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ViewPhraseComponent.kt new file mode 100644 index 0000000000..99e7dcf23f --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ViewPhraseComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface ViewPhraseComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt new file mode 100644 index 0000000000..d82fbd968e --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.hotwallet.viewphrase + +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.hotwallet.ViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.model.ViewPhraseModel +import com.tangem.features.hotwallet.viewphrase.ui.ViewPhraseContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultViewPhraseComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: ViewPhraseComponent.Params, +) : ViewPhraseComponent, AppComponentContext by context { + private val model: ViewPhraseModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + ViewPhraseContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : ViewPhraseComponent.Factory { + override fun create( + context: AppComponentContext, + params: ViewPhraseComponent.Params, + ): DefaultViewPhraseComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/di/ViewPhraseModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/di/ViewPhraseModule.kt new file mode 100644 index 0000000000..e6b6d7d4ab --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/di/ViewPhraseModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.hotwallet.viewphrase.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.ViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.DefaultViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.model.ViewPhraseModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(SingletonComponent::class) +internal interface ViewPhraseModule { + + @Binds + @IntoMap + @ClassKey(ViewPhraseModel::class) + fun bindViewPhraseModel(model: ViewPhraseModel): Model + + @Binds + fun bindViewPhraseComponentFactory(factory: DefaultViewPhraseComponent.Factory): ViewPhraseComponent.Factory +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/entity/ViewPhraseUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/entity/ViewPhraseUM.kt new file mode 100644 index 0000000000..072bed311d --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/entity/ViewPhraseUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.hotwallet.viewphrase.entity + +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data class ViewPhraseUM( + val onBackClick: () -> Unit, + val words: ImmutableList = persistentListOf(), +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt new file mode 100644 index 0000000000..f2d3adf53e --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt @@ -0,0 +1,76 @@ +package com.tangem.features.hotwallet.viewphrase.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +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.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase +import com.tangem.domain.wallets.usecase.ExportSeedPhraseUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.hotwallet.ViewPhraseComponent +import com.tangem.features.hotwallet.viewphrase.entity.ViewPhraseUM +import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class ViewPhraseModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val exportSeedPhraseUseCase: ExportSeedPhraseUseCase, + private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, +) : Model() { + + private val params = paramsContainer.require() + + private var hotWalletId: HotWalletId? = null + + internal val uiState: StateFlow + field = MutableStateFlow( + ViewPhraseUM( + onBackClick = { router.pop() }, + ), + ) + + init { + loadSeedPhrase() + } + + private fun loadSeedPhrase() { + val userWallet = getUserWalletUseCase(params.userWalletId) + .getOrElse { error("User wallet with id ${params.userWalletId} not found") } + if (userWallet is UserWallet.Hot) { + hotWalletId = userWallet.hotWalletId + modelScope.launch { + val words = exportSeedPhraseUseCase.invoke(userWallet.hotWalletId) + .getOrElse { error("Unable to export seed phrase for wallet with id ${params.userWalletId}") } + .mnemonic + .mnemonicComponents + uiState.update { + it.copy( + words = words.mapIndexed { index, s -> + EnumeratedTwoColumnGridItem(index + 1, s) + }.toImmutableList(), + ) + } + } + } + } + + override fun onDestroy() { + hotWalletId?.let { clearHotWalletContextualUnlockUseCase.invoke(it) } + super.onDestroy() + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/ui/ViewPhraseContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/ui/ViewPhraseContent.kt new file mode 100644 index 0000000000..8768e09a55 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/ui/ViewPhraseContent.kt @@ -0,0 +1,103 @@ +package com.tangem.features.hotwallet.viewphrase.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.impl.R +import com.tangem.features.hotwallet.viewphrase.entity.ViewPhraseUM +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun ViewPhraseContent(state: ViewPhraseUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + AppBarWithBackButton( + text = stringResourceSafe(R.string.common_backup), + onBackClick = state.onBackClick, + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .imePadding() + .weight(1f), + ) { + TitleBlock( + state = state, + modifier = Modifier.padding(top = 20.dp), + ) + + EnumeratedTwoColumnGrid( + items = state.words, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 32.dp), + ) + } + } +} + +@Composable +private fun TitleBlock(state: ViewPhraseUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(horizontal = TangemTheme.dimens.size36) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResourceSafe(R.string.backup_seed_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResourceSafe( + R.string.backup_seed_caution, + state.words.size, + ), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + ViewPhraseContent( + state = ViewPhraseUM( + onBackClick = {}, + words = List(12) { + EnumeratedTwoColumnGridItem( + index = it + 1, + mnemonic = "word${it + 1}", + ) + }.toImmutableList(), + ), + ) + } +} \ No newline at end of file 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 20c094ed28..b7357044d5 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 @@ -1,38 +1,33 @@ package com.tangem.features.hotwallet.walletbackup.model -import com.tangem.core.decompose.di.GlobalUiMessageSender 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.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.icon -import com.tangem.core.ui.components.bottomsheets.message.infoBlock -import com.tangem.core.ui.components.bottomsheets.message.onClick -import com.tangem.core.ui.components.bottomsheets.message.secondaryButton 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.bottomSheetMessage import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.common.routing.AppRoute +import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase 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 kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @ModelScoped internal class WalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, - getWalletUseCase: GetUserWalletUseCase, - private val router: Router, + private val getWalletUseCase: GetUserWalletUseCase, + private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, override val dispatchers: CoroutineDispatcherProvider, - @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val router: Router, ) : Model() { private val params: WalletBackupComponent.Params = paramsContainer.require() @@ -57,34 +52,16 @@ internal class WalletBackupModel @Inject constructor( ), ) - private val makeBackupAtFirstAlertBS - get() = bottomSheetMessage { - infoBlock { - icon(R.drawable.ic_passcode_lock_32) { - type = MessageBottomSheetUMV2.Icon.Type.Accent - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint - } - title = resourceReference(R.string.hw_backup_need_title) - body = resourceReference(R.string.hw_backup_need_description) - } - secondaryButton { - text = resourceReference(R.string.hw_backup_need_action) - onClick { - router.push(AppRoute.CreateWalletBackup(params.userWalletId)) - closeBs() - } - } - } - init { - getWalletUseCase.invokeFlow(params.userWalletId) - .map { it.getOrNull() } - .distinctUntilChanged() - .filterNotNull() - .onEach { - updateBackupStatuses(it) - } - .launchIn(modelScope) + getWalletUseCase.invoke(params.userWalletId) + .fold( + ifLeft = { + Timber.e("Error on getting user wallet: $it") + }, + ifRight = { + updateBackupStatuses(it) + }, + ) } private fun updateBackupStatuses(userWallet: UserWallet) { @@ -118,9 +95,37 @@ internal class WalletBackupModel @Inject constructor( private fun onRecoveryPhraseClick() { if (uiState.value.backedUp) { - // TODO [REDACTED_TASK_KEY] + getWalletUseCase.invoke(params.userWalletId) + .fold( + ifLeft = { + Timber.e("Error on getting user wallet: $it") + }, + ifRight = { userWallet -> + when (userWallet) { + is UserWallet.Cold -> { + val userWalletId = userWallet.walletId + Timber.e("Unexpected cold wallet when request seed phrase: $userWalletId") + } + is UserWallet.Hot -> showSeedPhrase(userWallet) + } + }, + ) } else { - uiMessageSender.send(makeBackupAtFirstAlertBS) + router.push(AppRoute.CreateWalletBackup(params.userWalletId)) + } + } + + private fun showSeedPhrase(hotWallet: UserWallet.Hot) { + modelScope.launch { + unlockHotWalletContextualUseCase.invoke(hotWallet.hotWalletId) + .fold( + ifLeft = { + Timber.e("Error while export seed phrase: $it") + }, + ifRight = { seedPhrasePrivateInfo -> + router.push(AppRoute.ViewPhrase(params.userWalletId)) + }, + ) } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 72c784bae5..80bdb6d116 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -11,7 +11,7 @@ tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-525" +tangemHotSdk = "develop-526" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 94a44864437c8400b83d969cd5f4dd02176d3140 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Sep 2025 13:19:12 +0300 Subject: [PATCH 054/132] Updated on 2026-08-14 --- .../tap/di/domain/SettingsDomainModule.kt | 10 +- .../di/UserWalletsListManagerModule.kt | 3 + .../DefaultUserWalletsListRepository.kt | 108 +++++++++------- .../utils/UserWalletEncyptionKeyCalculator.kt | 4 + .../converter/UserWalletItemUMConverter.kt | 8 +- .../local/preferences/PreferencesKeys.kt | 6 +- core/res/src/main/res/values-de/strings.xml | 4 +- core/res/src/main/res/values-es/strings.xml | 119 +++++++++--------- core/res/src/main/res/values-fr/strings.xml | 18 ++- core/res/src/main/res/values-ru/strings.xml | 13 +- .../src/main/res/values-uk-rUA/strings.xml | 12 +- core/res/src/main/res/values/strings.xml | 93 +++++++++++++- .../settings/DefaultSettingsRepository.kt | 8 +- ...eCase.kt => SetAskBiometryShownUseCase.kt} | 4 +- ...ase.kt => ShouldShowAskBiometryUseCase.kt} | 4 +- .../repositories/SettingsRepository.kt | 4 +- .../biometry/impl/model/AskBiometryModel.kt | 6 +- .../details/model/UserWalletListModel.kt | 2 +- .../hotwallet/accesscode/AccessCodeModel.kt | 61 +++++++++ .../entry/impl/model/OnboardingEntryModel.kt | 2 +- .../model/MultiWalletFinalizeModel.kt | 14 ++- .../wallet/utils/UserWalletsFetcher.kt | 2 +- .../wallet/child/wallet/model/WalletModel.kt | 19 ++- .../model/WalletsUpdateActionResolver.kt | 14 +-- .../MultiWalletWarningsSubscriber.kt | 6 + .../wallet/utils/DefaultUserWalletsFetcher.kt | 23 +++- .../connections/utils/WcUserWalletsFetcher.kt | 2 +- .../welcome/impl/model/WelcomeModel.kt | 2 +- 28 files changed, 393 insertions(+), 178 deletions(-) rename domain/settings/src/main/java/com/tangem/domain/settings/{SetSaveWalletScreenShownUseCase.kt => SetAskBiometryShownUseCase.kt} (71%) rename domain/settings/src/main/java/com/tangem/domain/settings/{ShouldShowSaveWalletScreenUseCase.kt => ShouldShowAskBiometryUseCase.kt} (57%) diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index df25dd1255..2cc898457b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -54,8 +54,8 @@ internal object SettingsDomainModule { @Singleton fun providesShouldShowSaveWalletScreenUseCase( settingsRepository: SettingsRepository, - ): ShouldShowSaveWalletScreenUseCase { - return ShouldShowSaveWalletScreenUseCase(settingsRepository = settingsRepository) + ): ShouldShowAskBiometryUseCase { + return ShouldShowAskBiometryUseCase(settingsRepository = settingsRepository) } @Provides @@ -138,10 +138,8 @@ internal object SettingsDomainModule { @Provides @Singleton - fun provideSetSaveWalletScreenShownUseCase( - settingsRepository: SettingsRepository, - ): SetSaveWalletScreenShownUseCase { - return SetSaveWalletScreenShownUseCase(settingsRepository = settingsRepository) + fun provideSetSaveWalletScreenShownUseCase(settingsRepository: SettingsRepository): SetAskBiometryShownUseCase { + return SetAskBiometryShownUseCase(settingsRepository = settingsRepository) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt index c521709443..f582dea772 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt @@ -8,6 +8,7 @@ import com.tangem.common.json.TangemSdkAdapter import com.tangem.common.services.secure.SecureStorage import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.models.scan.serialization.* import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.domain.visa.model.VisaCardActivationStatus @@ -122,6 +123,7 @@ internal object UserWalletsListManagerModule { passwordRequester: HotWalletPasswordRequester, appPreferencesStore: AppPreferencesStore, hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, + scanCardProcessor: ScanCardProcessor, ): UserWalletsListRepository { val moshi = buildMoshi() val secureStorage = buildSecureStorage(applicationContext = applicationContext) @@ -168,6 +170,7 @@ internal object UserWalletsListManagerModule { appPreferencesStore = appPreferencesStore, savePersistentInformation = ProviderSuspend { true }, // Always save persistent information for now hotWalletAccessCodeAttemptsRepository = hotWalletAccessCodeAttemptsRepository, + scanCardProcessor = scanCardProcessor, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 87edec2202..c6204135b7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -11,6 +11,7 @@ import com.tangem.common.map import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked @@ -38,6 +39,9 @@ import com.tangem.utils.ProviderSuspend import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import com.tangem.core.analytics.models.AnalyticsParam +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Suppress("LongParameterList", "LargeClass") internal class DefaultUserWalletsListRepository( @@ -50,36 +54,45 @@ internal class DefaultUserWalletsListRepository( private val savePersistentInformation: ProviderSuspend, private val appPreferencesStore: AppPreferencesStore, private val hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, + private val scanCardProcessor: ScanCardProcessor, ) : UserWalletsListRepository { override val userWallets = MutableStateFlow?>(null) override val selectedUserWallet = MutableStateFlow(null) + private val mutex = Mutex() override suspend fun load() { - if (userWallets.value != null) return + mutex.withLock { + if (userWallets.value != null) return - if (savePersistentInformation().not()) { - // If we don't save persistent information, we don't need to load user wallets - // and we should clear any existing data - clearPersistentData() - updateWallets { emptyList() } - return - } - - val unsecuredEncryptionKeys = userWalletEncryptionKeysRepository.getAllUnsecured() - - publicInformationRepository.getAll() - .map { it.toUserWallets() } - .flatMap { wallets -> - sensitiveInformationRepository.getAll(unsecuredEncryptionKeys) - .map { wallets.updateWith(it) } - }.doOnSuccess { - userWallets.value = it + if (savePersistentInformation().not()) { + // If we don't save persistent information, we don't need to load user wallets + // and we should clear any existing data + clearPersistentData() + updateWallets { emptyList() } + return } - val selectedUserWalletId = selectedUserWalletRepository.get() - selectedUserWallet.value = userWallets.value?.firstOrNull { it.walletId == selectedUserWalletId } - ?: userWallets.value?.firstOrNull() + val unsecuredEncryptionKeys = userWalletEncryptionKeysRepository.getAllUnsecured() + + publicInformationRepository.getAll() + .map { it.toUserWallets() } + .flatMap { wallets -> + sensitiveInformationRepository.getAll(unsecuredEncryptionKeys) + .map { wallets.updateWith(it) } + } + .doOnSuccess { loadedWallets -> + userWallets.update { toUpdate -> + val selectedUserWalletId = selectedUserWalletRepository.get() + selectedUserWallet.value = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId } + ?: loadedWallets.firstOrNull()?.also { + selectedUserWalletRepository.set(it.walletId) + } + + loadedWallets + } + } + } } override suspend fun userWalletsSync(): List { @@ -191,18 +204,17 @@ internal class DefaultUserWalletsListRepository( userWalletEncryptionKeysRepository.delete(userWalletIds) - val userWalletsBeforeDelete = userWallets.value ?: return@either - userWallets.update { currentWallets -> - currentWallets?.filterNot { it.walletId in userWalletIds } - } - - selectedUserWallet.update { currentSelected -> - if (currentSelected == null) return@update null - - userWallets.value?.findAvailableUserWallet( - userWalletsBeforeDelete.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, - ) + val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } + selectedUserWallet.update { currentSelected -> + if (currentSelected == null) return@update null + val newSelected = updatedWallets?.findAvailableUserWallet( + currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, + ) + selectedUserWalletRepository.set(newSelected?.walletId) + newSelected + } + updatedWallets } } @@ -258,7 +270,7 @@ internal class DefaultUserWalletsListRepository( raise(UnlockWalletError.UnableToUnlock) } - tangemSdkManagerProvider().scanProduct() + scanCardProcessor.scan(analyticsSource = AnalyticsParam.ScreensSources.SignIn) .doOnSuccess { scanResponse -> val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build() @@ -266,9 +278,14 @@ internal class DefaultUserWalletsListRepository( raise(UnlockWalletError.ScannedCardWalletNotMatched) } - saveWithoutLock(userWallet.copy(scanResponse = scanResponse), canOverride = true) - .mapLeft { UnlockWalletError.UnableToUnlock } - .bind() + val encryptionKey = UserWalletEncryptionKey( + walletId = userWallet.walletId, + encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock), + ) + + sensitiveInformationRepository.getAll(listOf(encryptionKey)) + .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } + .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) } } .doOnFailure { raise(UnlockWalletError.UserCancelled) @@ -278,7 +295,8 @@ internal class DefaultUserWalletsListRepository( } override suspend fun unlockAllWallets(): Either = either { - val userWalletIds = userWalletsSync().map { it.walletId }.toSet() + val userWallets = userWalletsSync() + val userWalletIds = userWallets.map { it.walletId }.toSet() val biometricKeys = runCatching { userWalletEncryptionKeysRepository.getAllBiometric() }.getOrElse { @@ -291,7 +309,7 @@ internal class DefaultUserWalletsListRepository( val unlockedWalletsIds = allKeys.map { it.walletId } val unlockedWallets = unlockedWalletsIds.mapNotNull { id -> - userWalletsSync().firstOrNull { it.walletId == id } + userWallets.firstOrNull { it.walletId == id } } // Remove all password attempts for unlocked hot wallets @@ -306,7 +324,7 @@ internal class DefaultUserWalletsListRepository( sensitiveInformationRepository.getAll(allKeys) .doOnSuccess { sensitiveInfo -> - updateWallets { it?.updateWith(sensitiveInfo) } + updateWallets { userWallets.updateWith(sensitiveInfo) } } .doOnFailure { raise(UnlockWalletError.UnableToUnlock) } } @@ -390,11 +408,13 @@ internal class DefaultUserWalletsListRepository( } private fun updateWallets(block: (List?) -> List?) { - userWallets.update(block) - - selectedUserWallet.update { currentSelected -> - if (currentSelected == null) return@update null - userWallets.value?.find { it.walletId == currentSelected.walletId } + userWallets.update { + val updated = block(it) + selectedUserWallet.update { currentSelected -> + if (currentSelected == null) return@update null + updated?.find { it.walletId == currentSelected.walletId } + } + updated } } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt index 8b05c741e7..65b8e925a7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/UserWalletEncyptionKeyCalculator.kt @@ -4,6 +4,7 @@ import com.tangem.common.extensions.calculateSha256 import com.tangem.domain.common.extensions.calculateHmacSha256 import com.tangem.domain.models.MobileWallet import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet internal val UserWallet.encryptionKey: ByteArray? @@ -19,6 +20,9 @@ private fun calculateEncryptionKey(publicKey: ByteArray): ByteArray { return message.calculateHmacSha256(keyHash) } +val ScanResponse.encryptionKey: ByteArray? + get() = findPublicKey(this.card.wallets)?.let { calculateEncryptionKey(it) } + private fun findPublicKey(wallets: List): ByteArray? { return wallets.firstOrNull()?.publicKey } diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt index aec881c569..a079675da1 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt @@ -34,7 +34,7 @@ class UserWalletItemUMConverter( private val appCurrency: AppCurrency? = null, private val balance: TotalFiatBalance? = null, private val isBalanceHidden: Boolean = false, - private val authMode: Boolean = false, + private val isAuthMode: Boolean = false, private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None, artwork: UserWalletItemUM.ImageState? = null, ) : Converter { @@ -58,11 +58,11 @@ class UserWalletItemUMConverter( } private fun isEnabled(userWallet: UserWallet): Boolean { - return authMode || userWallet.isLocked.not() + return isAuthMode || userWallet.isLocked.not() } private fun getLabelOrNull(userWallet: UserWallet): LabelUM? { - return if (authMode.not() && userWallet is UserWallet.Hot && !userWallet.backedUp) { + return if (isAuthMode.not() && userWallet is UserWallet.Hot && !userWallet.backedUp) { LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, @@ -92,7 +92,7 @@ class UserWalletItemUMConverter( private fun getBalanceInfo(userWallet: UserWallet): UserWalletItemUM.Balance { return when { userWallet.isLocked -> UserWalletItemUM.Balance.Locked - authMode -> UserWalletItemUM.Balance.NotShowing + isAuthMode -> UserWalletItemUM.Balance.NotShowing isBalanceHidden -> UserWalletItemUM.Balance.Hidden balance == null -> UserWalletItemUM.Balance.Loading else -> { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 8644fb72c8..63fbdfebf8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -7,7 +7,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TANGEM_TOS_ACC import com.tangem.datasource.local.preferences.PreferencesKeys.SAVE_USER_WALLETS_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_OPEN_WELCOME_ON_RESUME_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY -import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY +import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY @@ -26,7 +26,7 @@ object PreferencesKeys { val SAVE_USER_WALLETS_KEY by lazy { booleanPreferencesKey(name = "saveUserWallets") } - val SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") } + val SHOULD_SHOW_ASK_BIOMETRY_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") } val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") } @@ -183,7 +183,7 @@ object PreferencesKeys { internal fun getTapPrefKeysToMigrate(): Set { return setOf( SAVE_USER_WALLETS_KEY, - SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, + SHOULD_SHOW_ASK_BIOMETRY_KEY, APP_LAUNCH_COUNT_KEY, SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY, FUNDS_FOUND_DATE_KEY, diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index d0bb34dd95..b7cc837f73 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -452,7 +452,7 @@ Aktuelle Preise werden abgerufen ... Variabler Zinssatz Durch die Nutzung der Swap-Funktion erklärst du dich mit den folgenden Bedingungen des Anbieters einverstanden %s - Durch die Nutzung der Swap-Funktionalität erklärst du dich mit des Anbieters %1$s und %2$s einverstanden + Durch die Nutzung der Swap-Funktionalität erklärst du dich mit des Anbieters %1$s und %2$s einverstanden. Weitere Anbieter folgen in Kürze Anbieter Bester Preis @@ -1039,7 +1039,7 @@ Senden... Berühre an beliebiger Stelle für Änderungen Versende %s - Du sendest **%1$s** inklusive der Netzwerkgebühr %2$s + Du sendest **%1$s** inklusive der Netzwerkgebühr %2$s. Du sendest **%1$s** und %2$s Du sendest **%1$s** Die Netzwerkgebühr wird durch die Nutzung von %1$s Energieträgern gedeckt. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 1e084899ad..16cac3a4f8 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -26,7 +26,7 @@ Gestionar tokens Envíe solo %1$s (%2$s) desde redes como %3$s a esta dirección. Usar otros tokens y redes puede resultar en la pérdida de fondos. Por defecto - Legado + Legacy Cómo escanear Solicitar soporte Inténtelo de nuevo @@ -65,8 +65,8 @@ Guarde estas %s palabras en un lugar seguro, como un administrador de contraseñas, y nunca las comparta con nadie. No se puede restaurar Frase de recuperación - Escriba estas palabras %s en orden y guárdelas en un lugar privado y seguro - La responsabilidad total de la seguridad y la copia de seguridad de la billetera y la frase de recuperación recae en el usuario, no en Tangem. + Escriba estas %s palabras en orden y guárdelas en un lugar privado y seguro + La responsabilidad total sobre la seguridad y copia de seguridad de la billetera y su frase de recuperación recae en el usuario, no en Tangem. Frase de recuperación Para ocultar o mostrar tus saldos, simplemente gira la pantalla de tu dispositivo hacia abajo, o desactívalo en Ajustes No mostrar de nuevo @@ -118,7 +118,7 @@ Requisitos de transacción de Cardano Para realizar una %1$s transacción, deba depositar algo de ADA para cubrir la tarifa de red y el valor mínimo de ADA (se recomiendan 5 ADA) ADA insuficiente para la transferencia de tokens - El monto enviado y el cambio no pueden ser menores a 1 ADA + La cantidad enviada y el cambio no pueden ser menores a 1 ADA Deba mantener algo de ADA porque tiene algunos tokens en la blockchain de Cardano ADA insuficiente Aceptar @@ -147,9 +147,9 @@ Cancelar Cambie Elige una acción - Elegir red - Elige token - Elegir wallet + Elija red + Elija token + Elija wallet Reclamar Reclame recompensas Cerrar @@ -221,7 +221,7 @@ Anillo primario Frase de contraseña Pegar - Política de privacidad + Política de privacidad %1$s-%2$s %1$s — %2$s Leer más @@ -254,10 +254,10 @@ Enviar Con éxito Soporte - Redes compatibles + Redes soportadas Swap términos y condiciones - Condiciones de uso + Condiciones de uso Hoy %d token @@ -271,7 +271,7 @@ Hubo un error. Por favor inténtelo de nuevo. Inaccesible Termine el staking - Debido a limitaciónes sobre %1$s, solos %2$d UTXO pueden caber en una sola transacción. Esto significa que solo puedes enviar %3$s o menos. Debe reducir la cantidad. + Debido a limitaciones sobre %1$s, solo %2$d UTXO pueden caber en una sola transacción. Esto significa que solo puedes enviar %3$s o menos. Debe reducir la cantidad. Valor copiado semana con @@ -334,7 +334,8 @@ Recibir activos %s dirección Enviar activos a otras redes resultará en una pérdida permanente. - Envíe fondos utilizando solo + Red de %s + Envíe fondos utilizando solo la Hola equipo de soporte, he encontrado un error con el código: %s Error de WalletConnect Ha usado una tarjeta de otra billetera. Toque la tarjeta asociada con esta billetera @@ -377,7 +378,7 @@ Esperando depósito Esperando depósito Reembolso completado - A la espera del reembolso + Esperando reembolso Transfiriendo a su cuenta Enviando fondos... Fondos enviados @@ -389,12 +390,12 @@ Buscar tarifas actuales... Tasa flotante Al utilizar la función de intercambio, acepta el %s - Al utilizar la función de intercambio, aceptas las condiciones de %1$s y %2$s del proveedor + Al utilizar la función de intercambio, aceptas las condiciones de %1$s y %2$s del proveedor. Pronto habrá más proveedores disponibles Proveedor Mejor tarifa Lista de advertencias de la FCA - Proveedor en la lista de advertencia de la FCA + Proveedor en la lista de advertencias de la FCA Disponible hasta %s Disponible desde %s No disponible para este par @@ -407,7 +408,7 @@ Una vez oculto, el estado de la transacción no se puede volver a ver. En su lugar, basta con deslizar el dedo para descartarla. ¿Ocultar el estado de la transacción? Este token no es compatible. Por favor, elige un token diferente para intercambiar. - %s no es compatible + %s no está soportado Intercambiar con No se encontraron fichas. Por favor intenta con otra solicitud ID : %s @@ -436,8 +437,8 @@ Para continuar, de autorización a los smart contracts de %1s para usar su %2s Dar autorización Ilimitado - Agregar billetera existente - Crear nueva billetera + Agregar Billetera Existente + Crear Nueva Billetera Pedir Tangem Escanee a %s @@ -524,7 +525,7 @@ Top Ganadores Top Perdedores Tendencias - Staking es la forma más fácil de recibir recompensas por tus criptomonedas. %s + Staking es la forma más fácil de recibir recompensas por sus criptomonedas. %s Gana hasta %s APY Acerca de %s @@ -605,14 +606,14 @@ Colecciones de NFT Es posible que algunos datos no se carguen Problemas temporales de carga - Información de base + Información básica Cadena Dirección del contrato Cadena es la blockchain en la que está el NFT. La dirección del contrato es un identificador único para el contrato inteligente que gobierna los tokens en la blockchain Una etiqueta que describe lo raro que es el NFT. Cuanto menor sea el valor, más único será el NFT. La posición de un NFT en el ranking de rareza entre otros tokens. Cuanto más alto sea el ranking, más raro y valioso será el NFT. - La dirección del contrato es un identificador único para el contrato inteligente que gobierna los tokens en la blockchain + La dirección del token es un identificador único del token en la blockchain, que permite el seguimiento de las transacciones y la propiedad El ID del token es un identificador único asignado a cada token, que lo distingue de otros en la colección. El estándar de token define qué tipo de token es y cómo funciona con diferentes billeteras y plataformas Último precio de venta @@ -642,13 +643,13 @@ Pulse aquí para recibir el primer NFT Colecciones de NFT No se pueden cargar los datos - Para utilizar la red %1$s, debe pagar la reserva de cuenta (%2$s%3$s), que bloquea y oculta ese monto indefinidamente + Para utilizar la red %1$s, debe pagar la reserva de cuenta (%2$s%3$s), que bloqueará y ocultará esa cantidad indefinidamente La cuenta de destino no está activa. Envíe %s o más para activar la cuenta. Para crear una cuenta, envíe fondos a esta dirección - La cuenta de destino no tiene una Trustline para el activo que se envía. - Únete ahora - Comparte tu código y gana 5 USDT por venta. Tu amigo obtiene un 10% de descuento. - ¡Obtén RECOMPENSAS por cada amigo! + La cuenta de destino no tiene una Trustline (línea de confianza) para el activo que se envía. + Únase ahora + Comparta su código y gane 5 USDT por venta. Su amigo obtiene un 10% de descuento. + ¡Obtenga RECOMPENSAS por cada amigo! Deba configurar un único código de acceso para proteger todss sus dispositivos. Proteger Puede configurar un código de acceso individual en cada tarjeta más adelante @@ -775,11 +776,11 @@ Organizar tokens Desagrupar Más información - Puedes activar las Notificaciones para Tangem en Ajustes. + Puede activar las Notificaciones para Tangem en Ajustes. Activar más tarde Ajustes Activar notificaciones - Reciba alertas de transacciones entrantes en las redes compatibles + Reciba alertas de transacciones entrantes en las redes soportadas Notificaciones de transacciones Seleccione de la galería Ajustes @@ -869,11 +870,11 @@ Introduce la dirección Nombre ENS o dirección La dirección es la misma que su billetera. - El monto mínimo es %s + La cantidad mínima es %s El cambio mínimo es %s Tarifa no válida El saldo mínimo es %s - No se ha creado la cuenta de destino. El monto a enviar debe ser %s + comisiones o más + No se ha creado la cuenta de destino. La cantidad a enviar debe ser %s + comisiones o más Error desconocido Memo no válido. No se añadirá a la transacción. Memo @@ -893,7 +894,7 @@ Memo no válido Cobertura de tarifa de red Nonce - Número único para cada transacción. Utilízalo para reenviar o cancelar una transacción pendiente. + Número único para cada transacción. Utilícelo para reenviar o cancelar una transacción pendiente. Introduzca nonce.. Fondos insuficientes para la transferencia, ya que el total de la tarifa y el importe de la transferencia supera el saldo existente El total supera el saldo @@ -934,7 +935,7 @@ Enviando... Toque cualquier campo para editarlo Enviar %s - Está enviando **%1$s** incluida una tarifa de red de %2$s + Está enviando **%1$s** incluida una tarifa de red de %2$s. Está enviando**%1$s** y %2$s Está enviando **%1$s** La tarifa de red se cubrirá al usar %1$s energía @@ -943,18 +944,18 @@ A la dirección La transacción se firmó con éxito y se envió al nodo blockchain. El saldo de la billetera se actualizará después de un tiempo. %1$s es un activo en la red Tron. Para calcular la tarifa y realizar una transacción, deba depositar algo de Tron (TRX) en su cuenta. - El monto excede el saldo + La cantidad excede el saldo Es necesario una etiqueta de destino (memo) para completar esta transacción para la dirección especificada. Etiqueta de destino obligatoria Cantidad no válida La tarifa excede el saldo - El monto total excede el saldo + La cantidad total excede el saldo Intercambiar y enviar ¿Continuar con la conversión? Esto borrará sus datos anteriores. Confirmar Conversión El envío de cualquier otra moneda supondrá su pérdida irreversible. Seleccione la red de destino correcta - Envía cualquier token y lo convertiremos en el camino. Su destinatario obtiene exactamente lo que necesita, sin problemas. + Elige cualquier token para recibir. Su destinatario recibirá exactamente lo que seleccionaste, sin problemas. El destinatario recibirá Al destinatario Cantidad a recibir @@ -966,7 +967,7 @@ Olvidar la billetera Esto eliminará la wallet de la aplicación. La wallet en sí puede\nañadirse de nuevo. Nombre - Haz que tu token trabaje por ti + Haga que su token trabaje para Ud. El monto del staking debe ser al menos %s El monto del staking se redondeará a %1$s TRX debido a las reglas de la red. El monto de cancelación del staking se redondeará a %1$s TRX debido a las reglas de la red. @@ -1101,7 +1102,7 @@ Compatible con Web 3.0 Se requiere una transacción entrante de al menos %1$s para proceder Fondos insuficientes - Tasa fija + Tasa Fija La red cobrará una tarifa de aprobación de token para verificar que está autorizando el uso de su token para el swap. Intercambie más tokens a mejores tasas directamente en su billetera. ¡Nuevo proveedor de intercambio disponible! @@ -1176,12 +1177,12 @@ desde: %s a: %s validador: %s - Las notificaciones están activadas, pero no funcionarán hasta que permitas las notificaciones en la configuración de tu dispositivo. + Las notificaciones están activadas, pero no funcionarán hasta que permita las notificaciones en la configuración de su dispositivo. Notificaciones de transacciones Mínimo %s El monto mínimo para realizar esta transacción es %1$s. - Las tarifas de la red Tron para tokens populares pueden ser más altas. Hacer staking de TRX puede ayudar a reducir los costos de transacción. - Ahorra en las tarifas de la red Tron + Las tarifas de la red Tron para tokens populares pueden ser más altas. Hacer staking de TRX puede ayudar a reducir costes de transacción. + Ahorre en las tarifas de la red Tron Inténtelo de nuevo Ha escaneado la misma tarjeta. Para crear una billetera gemela, necesite escanear la tarjeta con no. %d Ha escaneado la tarjeta gemela incorrecta. Por favor, intente con otra @@ -1196,7 +1197,7 @@ Toque la tarjeta gemela con el número %s y no la retira hasta el final de la operación Por favor, inténtelo de nuevo más tarde. Si el problema persiste, póngase en contacto con el servicio de asistencia. ¡Algo salió mal! - Hemos encontrado un error. Código de error: %s. Póngase en contacto con nuestro servicio de asistencia. + Hemos encontrado un error. Código de error: %s. Póngase en contacto con nuestro servicio de soporte. Use %s o escanee una tarjeta/anillo para tener acceso a su billetera Error de conexión: Esta dApp utiliza la versión 1.0 de Wallet Connect, que no es compatible. Asegúrese de que la dApp sea compatible con la versión 2.0 de Wallet Connect para conectarse correctamente. Manténgase actualizado con las últimas funciones y noticias @@ -1260,7 +1261,7 @@ Accede a más de 13 000 criptomonedas. Compra, vende, intercambia y realiza staking con un solo toque.\nVincula hasta tres tarjetas para hacer copias de seguridad. Descubre Tangem Wallet Cambiar código de acceso - Manténteinformado sobre las transacciones entrantes de la billetera y las actualizaciones de Tangem. + Manténgase informado sobre las transacciones entrantes de la billetera y las actualizaciones de Tangem. Notificaciones de transacciones Establecer código de acceso Ajustes de la wallet @@ -1297,7 +1298,7 @@ El monto a recibir debe ser de al menos %s Esto puede suceder porque el proveedor actualmente no puede operar con el par que usted seleccionó. Espere un momento e inténtelo de nuevo. (Código %s) El par seleccionado no está disponible temporalmente - Algunos proveedores no están autorizados por la Autoridad de Conducta Financiera (FCA) del Reino Unido. Deberías evitar tratar con ellos. + Algunos proveedores no están autorizados por la Autoridad de Conducta Financiera del Reino Unido. Debe evitar tratar con ellos. Servicio no disponible temporalmente La cantidad de tokens a intercambiar no debe exceder %s El monto a cambiar debe ser de al menos %s @@ -1333,10 +1334,10 @@ Su opinión nos motiva a hacer Tangem Wallet aún mejor ¿Disfrutando de Tangem? Deba asociar su token antes de recibir tokens - Debes abrir una línea de confianza para tu token antes de recibirlo + Debe abrir una trustline para su token antes de recibirlo Se requiere tarifa de alquiler de red Acción requerida - ¿Te pusiste en contacto con el servicio de asistencia a través de la aplicación o por correo electrónico en los 7 días posteriores a la creación de la billetera? Si lo hiciste o no estás seguro, sigue y completa las instrucciones. + ¿Se puso en contacto con el servicio de soporte a través de la app en los 7 días posteriores a la creación de la billetera? Si lo hiciste o no estás seguro, pulse en \"Sí\" y complete las instrucciones. ¡Gracias! ¡Todo listo! No se requieren más acciones. Ahora será redirigido al sitio web oficial de Tangem. Por favor, lea y siga las instrucciones allí indicadas. ¿Alguna vez contactó al equipo de soporte de Tangem directamente desde esta aplicación? @@ -1353,16 +1354,16 @@ Esta es una tarjeta de Testnet. No puede procesar transacciones y solo deba usarse para pruebas y desarrollo. Solo para fines de prueba El saldo podría estar desactualizado. Actualice la página. - No hay suficiente %1$s. Recarga tu %2$s cuenta para asociar este token - Habilitar línea de confianza - Una línea de confianza debe estar habilitada para recibir este token. La red requiere un %1$s %2$s reserva. - Se requiere línea de confianza + No hay suficiente %1$s. Recargue su cuenta %2$s para asociar este token + Habilitar Trustline + Una trustline (línea de confianza) debe estar habilitada para recibir este token. La red requiere un %1$s %2$s reserva. + Se requiere trustline (línea de confianza) La red requerida %s no está añadida a su portafolio. Añádala primero y luego continúe con la conexión. Agregue red al portafolio Dominio malicioso Dominio desconocido Conectarse de todas formas - Error de tiempo de espera. Por favor, inténtalo de nuevo más tarde. + Error de tiempo de espera. Por favor, inténtelo de nuevo más tarde. Error al establecer WalletConnect Este dominio no puede ser verificado. Compruebe cuidadosamente la solicitud de aprobación. Para continuar, vuelva a conectar su sesión de dApp con la red requerida %s. @@ -1382,10 +1383,10 @@ Esta red %s no es compatible con Tangem Wallet y no puede conectarse. Red no compatible Actualmente, Tangem no es compatible con una red requerida por %s. - Redes no compatibles + Redes no soportadas Este dominio ha superado las verificaciones y se considera seguro, confiable y libre de amenazas conocidas o actividades sospechosas. Dominio verificado - Se seleccionó una tarjeta o un anillo incorrectos en la app + Se seleccionó una tarjeta o un anillo incorrectos en la App Tenemos algún tipo de problema Todas las dApps desconectadas Permitir gastar @@ -1414,22 +1415,22 @@ Todas las sesiones de dApp se desconectarán. Su billetera ya no estará vinculada a ninguna dApp. Desconectar todas las dApps Intente emparejar nuevamente con una URI nueva - Dominio de dApp no válido + Dominio de dApp incorrecto %s no especifica ninguna blockchain, ni obligatoria ni opcional. Asegúrese de haber utilizado el URI correcto Sin redes Por favor, genere una nueva URI e intente conectarse nuevamente Propuesta de conexión caducada Cambios estimados en la billetera La transacción no ha podido ser simulada. Por favor, proceda con precaución. - La estimación no es compatible con %s + La estimación no está soportada para %s Sugerido por %s Recargue su saldo para cubrir la tarifa de red Insuficiente %1$s Transacción maliciosa - Añada la red %s a su perfil para esta billetera + Añada la red %s a su portafolio para esta billetera La billetera no tiene las redes requeridas Nueva conexión - Conecta tu billetera a diferentes dApps + Conecte su billetera a diferentes dApps Sin sesiones No se han detectado cambios en la billetera Se han detectado riesgos potenciales o comportamiento malicioso. Conectarse o firmar transacciones puede resultar en la pérdida de fondos. @@ -1437,7 +1438,7 @@ Abra la aplicación Web3 y elija la opción WalletConnect Solicitud de Firmar de todos modos - Tipo de firma + Tipo de Firma Se requiere al menos una red para la conexión dApp Especificar las redes seleccionadas Firmado correctamente @@ -1461,6 +1462,6 @@ Use %s o escanee una tarjeta/anillo para acceder a la app ¡Bienvenido de nuevo! No, enviar todo - Réduire de %s XTZ - Para evitar pagar una comisión mayor la próxima vez que recargue su billetera, reduzca el monto en %s + Reducir en %s XTZ + Para evitar pagar una comisión mayor la próxima vez que recargue su billetera, reduzca el importe en %s XTZ diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 228fa133e0..0d2ee7b831 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -198,7 +198,7 @@ Bague principale Passphrase Coller - Politique de confidentialité + Politique de confidentialité %1$s-%2$s %1$s — %2$s En savoir plus @@ -234,7 +234,7 @@ Réseaux pris en charge Échanger termes et conditions - Conditions d\'utilisation + Conditions d\'utilisation Aujourd\'hui %d jeton @@ -311,6 +311,7 @@ Recevoir des actifs %s adresse L’envoi d’actifs sur d’autres réseaux entraînera une perte définitive. + %s réseau Envoyez des fonds en utilisant uniquement Bonjour équipe de support, j’ai rencontré une erreur avec le code : %s Erreur WalletConnect @@ -365,7 +366,7 @@ En cherche des meilleurs taux Taux flottant En utilisant la fonctionnalité d\'échange, vous acceptez les %s - En utilisant la fonctionnalité d\'échange, vous acceptez les conditions %1$s et %2$s du fournisseur + En utilisant la fonctionnalité d\'échange, vous acceptez les conditions %1$s et %2$s du fournisseur. D\'autres fournisseurs arriveront bientôt.\nRestez branchés ! Fournisseur Meilleur taux @@ -908,7 +909,7 @@ En cours d\'envoi Appuyez sur n\'importe quel champ pour le modifier Envoyer %s - Vous envoyez **%1$s** incluant des frais de réseau de %2$s + Vous envoyez **%1$s** incluant des frais de réseau de %2$s. Vous envoyez **%1$s** et %2$s Vous envoyez **%1$s** les frais de réseau seront couverts en utilisant %1$s énergie @@ -923,11 +924,20 @@ Montant invalide Les frais de commissions dépassent le solde Le total dépasse le solde + Êtes-vous sûr de vouloir modifier le token de réception ? Cela réinitialisera les données que vous avez saisies précédemment. + Changement de token Échanger et envoyer + Poursuivre l\'échange ? Cela effacera vos données précédentes. + Confirmer la conversion + L\'envoi de toute autre crypto entraînera sa perte irréversible. + Sélectionnez le réseau destinataire approprié. Envoyez n\'importe quel jeton et nous le convertirons en cours de route. Votre destinataire reçoit exactement ce dont il a besoin, en toute simplicité. Le destinataire recevra Un destinataire sera envoyé Montant à recevoir + Le destinataire reçoit %s + Êtes-vous sûr de vouloir annuler la conversion ? Vos données précédentes seront effacées. + Supprimer la conversion Envoyer avec swap Transaction envoyée Scannez la carte/ bague que vous souhaitez configurer. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 35ce29d63a..34e2073bb1 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -4,6 +4,7 @@ Вы архивируете свой аккаунт, но в любое время можете вернуть его обратно Аккаунт Новый аккаунт + Основной аккаунт Продолжить редактирование Несохраненные изменения Не нашли свой токен? Перейдите в раздел «Рынок» на главной странице и добавьте его в свой портфель для покупки. @@ -204,7 +205,7 @@ Основное кольцо Парольная фраза Вставить - Политикой конфиденциальности + Политикой конфиденциальности %1$s-%2$s %1$s — %2$s Подробнее @@ -237,7 +238,7 @@ Поддержка Обменять условия участия - Условиями использования + Условиями использования Сегодня %d токен @@ -316,7 +317,7 @@ Получить активы %s адрес Отправка средств в другой сети может повлечь потерю средств. - %s сети + %s сеть Отправляйте средства, используя только Привет, команда поддержки, у меня возникла ошибка с кодом: %s Ошибка WalletConnect @@ -337,7 +338,7 @@ Обмен через %s Чтобы вернуть ваши деньги, посетите сайт провайдера Операция не выполнена провайдером - Ваш обмен занимает больше времени, чем ожидалось. Пожалуйста, обратитесь в службу поддержки провайдера для уточнения. + Ваш обмен занимает больше времени, чем обычно, но ваши средства полностью в безопасности и будут доставлены. Если возникли вопросы, вы всегда можете связаться с поддержкой провайдера. Отправленные средства были возвращены в %1$s на ваш кошелек в соответствии с правилами OKX или моста обмена. %2$s Сумма была возвращена в %1$s (%2$s сети) Посетите сайт провайдера для проверки @@ -370,7 +371,7 @@ Получение наилучших курсов... Плавающая ставка Пользуясь сервисом, вы соглашаетесь с %s - Пользуясь сервисом, вы соглашаетесь с %1$s и %2$s + Пользуясь сервисом, вы соглашаетесь с %1$s и %2$s. Больше провайдеров на подходе.\nСледите за обновлениями! Провайдер Лучший курс @@ -929,7 +930,7 @@ Отправка Нажмите на любое поле, чтобы изменить его Отправка %s - Вы отправляете **%1$s**, включая комиссию сети %2$s + Вы отправляете **%1$s**, включая комиссию сети %2$s. Вы отправляете **%1$s** и %2$s Вы отправляете **%1$s** Комиссия сети будет покрыта за счет использования %1$s энергии diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 709edd7c9e..33db0a745b 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -173,6 +173,7 @@ Синхронізувати адреси Перейти до провайдера Перейти до токену + Зrozуміло Приховати година Імпортувати @@ -196,7 +197,7 @@ Основне кільце Парольна фраза Вставити - Політикою конфіденційності + Політикою конфіденційності %1$s-%2$s %1$s — %2$s Детальніше @@ -230,7 +231,7 @@ Підтримка Обмін умови участі - Умовами використання + Умовами використання Сьогодні %d токен @@ -307,6 +308,7 @@ Отримати активи %s адреса Надсилання активів в інші мережі призведе до безповоротної втрати. + %s мережа Надсилайте кошти, використовуючи лише Привіт, команда підтримки, я зіткнувся з помилкою з кодом: %s Помилка WalletConnect @@ -327,7 +329,7 @@ Обмін через %s Щоб повернути ваші кошти, відвідайте сайт провайдера Операція не виконана провайдером - Ваш обмін триває довше, ніж очікувалося. Будь ласка, зверніться до служби підтримки постачальника послуг, для уточнення. + Ваш обмін триває довше, ніж зазвичай, але ваші кошти повністю в безпеці та будуть доставлені. Якщо ви хвилюєтесь, ви завжди можете звернутися до служби підтримки провайдера. Тривалий час транзакції Сума транзакції була повернута в %1$s на ваш гаманець відповідно до правил OKX або мосту обміну. %2$s Сума була повернута в %1$s (%2$s мережі) @@ -361,7 +363,7 @@ Шукаємо найвигідніший курс... Плаваюча ставка Використовуючи сервіс обміну, ви погоджуєтеся з його %s - Використовуючи сервіс обміну, ви погоджуєтеся з його %1$s та %2$s + Використовуючи сервіс обміну, ви погоджуєтеся з його %1$s та %2$s. Незабаром з\'являться нові провайдери.\nСлідкуйте за новинами! Провайдер Найкращий курс @@ -910,7 +912,7 @@ Надсилання... Торкніться будь-якого поля, щоб змінити його Надіслати %s - Ви надсилаєте **%1$s**, включно з комісію мережі %2$s + Ви надсилаєте **%1$s**, включно з комісію мережі %2$s. Ви надсилаєте **%1$s** і %2$s Ви надсилаєте **%1$s** комісія мережі буде покрита, використовуючи %1$s енергію diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 551ec44606..7d747732ea 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -35,6 +35,7 @@ Add account Edit account %1$s in %2$s + Main account Account recovered Long tap on an account to reorder accounts Keep Editing @@ -182,6 +183,7 @@ Allow Amount Analytics + and Apply Approval Approve @@ -255,6 +257,7 @@ Import In progress Later + Learn more %1$s left Legacy Bitcoin Locked @@ -280,7 +283,7 @@ Primary ring Passphrase Paste - Privacy Policy + Privacy Policy %1$s-%2$s %1$s — %2$s Read more @@ -319,7 +322,7 @@ Tangem Tangem Wallet terms and conditions - Terms of Use + Terms of Use Today %d token @@ -419,7 +422,7 @@ Exchange by %s Visit provider’s website to refund your money Operation failed by provider - Your swap is taking longer than expected. Please go to provider support for assistance. + Your swap is taking longer than usual, but your funds are completely safe and will be delivered. For any questions, you can contact the provider’s support team. Long transaction time The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s The amount was refunded in %1$s (%2$s network) @@ -453,7 +456,7 @@ Fetching current rates... Floating rate By using swap functionality, you agree with provider’s %s - By using swap functionality, you agree with provider’s %1$s and %2$s + By using swap functionality, you agree with provider’s %1$s and %2$s. More providers will be available soon Provider Best rate @@ -504,6 +507,7 @@ Create New Wallet Order Tangem Scan Tangem + Do you want to allow “Tangem” to use biometric authentication? To confirm your identity and open the app to %s On %s network Are you sure you want to exit the access code creation process? @@ -800,6 +804,7 @@ Another wallet has already been created on the card you\'re trying to add. If you have funds in this wallet, please withdraw it and then reset this card and add it as a backup. Save your wallet Creating a backup + Use biometrics Read more about seed phrase @@ -853,6 +858,7 @@ Access code restore Identical cards Access code + All offers Available with %s Providers facilitate transactions Search by country @@ -860,13 +866,20 @@ Other currencies Popular Fiats Search by currency + Instant By using onramp functionality, you agree with provider’s %1$s and %2$s The purchase amount should be no more than %s The amount to buy must be at least %s No available providers for this currency + Fastest Pay with + Payment method Available up to %s Available from %s + %d providers + Providers + Recently used + Recommended You will be able to complete your transaction on the third-party provider, %s Redirecting to %s... Our services are not available in this country @@ -875,6 +888,12 @@ Please select the correct country to ensure accurate payment options and services. Settings You can close this screen and check the transaction status on the token details screen. + + up to %d day + up to %d days + + %s min + You get You can close this screen and check the transaction status on the token details screen. Via You will pay @@ -1043,7 +1062,7 @@ Sending... Tap any field to change it Send %s - You are sending **%1$s** including a network fee of %2$s + You are sending **%1$s** including a network fee of %2$s. You are sending **%1$s** and %2$s You are sending **%1$s** network fee will be covered by using %1$s energy @@ -1668,4 +1687,68 @@ No, send all Reduce by %s XTZ To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ + Give Approve + Something went wrong with your previous approval, so we need a new one. Choose how you’d like to proceed. + Approve nedeed + The fee will be taken out, and your assets will be lent again. + To continue earning, approval is required. + Confirm approval + Text about your money in balance + Your %s is deposited in Aave + Earn %s% + Available + Current APY + My Funds + Your %1$s is now deposited in Aave and earning interest. You hold a%2$s token, which represents your balance and grows over time. When you top up, funds go to Aave to earn interest, minus a transaction fee. + Read more + Active + Stop earning + Earn + Total earnings + Automatic + Transfers to Aave + Text explaining that your savings are located in the protocol + Explore Aave + Explore default + Your %s is deposited in Aave + This is the current supply fee on %s. The live cost will be shown on the Receive Screen. + Current fee + All future %s top-ups will be supplied to Aave automatically, with the transaction fee deducted. + If network fees rise above maximum fee, the transaction won’t go through until they decrease. You can change this limit later. + Maximum fee + Fee policy + Write description here. In one, two or three lines will be awesome. + Some token approve needed + Every top-up of your account will be lended to Aave automatically. + Your balance works automatically + Send, swap, or sell your funds instantly, anytime you want. + Cash out instantly + How it works? + Aave is trusted by millions worldwide. Total lended value is $10.4B. + Decentralized and self-custodial + By using service, you agree with provider + Maximize your savings + Aave • Variable Interest Rate + Aave + Avg %s + Last year returns + Current interest rate is always variable and automatically computed by AAVE on-chain smart-contract based on real-time supply and demand. + Interest rate is variable + When you top up, your funds will be automatically sent to Aave to start earning interest. A small fee equal to %s will be deducted to cover the transaction. + Start earning + Your %s will be supplied to Aave and will stay instantly available + See fee policy + Network fee + Your next deposits will be automatically supplied to Aave. + Start earning + Turning off will withdraw your funds from Aave, return them to %s in your wallet, and stop earning rewards. + The network fee will be deducted from the amount you withdraw. + Stop earning + APY + Make your money work — earn interest on your balance. + Earning on your balance + Processing your deposit + Earn %s% per year + Deposit some %1$s %2$s to cover the network fee for transactions + Unable to cover %s fee diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index e27fce119a..5412605960 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -29,15 +29,15 @@ internal class DefaultSettingsRepository( private val userCountryFlow = MutableStateFlow(value = null) - override suspend fun shouldShowSaveUserWalletScreen(): Boolean { + override suspend fun shouldShowAskBiometry(): Boolean { return appPreferencesStore.getSyncOrDefault( - key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, + key = PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY, default = true, ) } - override suspend fun setShouldShowSaveUserWalletScreen(value: Boolean) { - appPreferencesStore.store(key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, value = value) + override suspend fun setShouldShowAskBiometry(value: Boolean) { + appPreferencesStore.store(key = PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY, value = value) } override suspend fun isWalletScrollPreviewEnabled(): Boolean { diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/SetSaveWalletScreenShownUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/SetAskBiometryShownUseCase.kt similarity index 71% rename from domain/settings/src/main/java/com/tangem/domain/settings/SetSaveWalletScreenShownUseCase.kt rename to domain/settings/src/main/java/com/tangem/domain/settings/SetAskBiometryShownUseCase.kt index 593b82a683..760ec572c6 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/SetSaveWalletScreenShownUseCase.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/SetAskBiometryShownUseCase.kt @@ -3,13 +3,13 @@ package com.tangem.domain.settings import arrow.core.Either import com.tangem.domain.settings.repositories.SettingsRepository -class SetSaveWalletScreenShownUseCase( +class SetAskBiometryShownUseCase( private val settingsRepository: SettingsRepository, ) { suspend operator fun invoke(): Either { return Either.catch { - settingsRepository.setShouldShowSaveUserWalletScreen(value = false) + settingsRepository.setShouldShowAskBiometry(value = false) } } } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSaveWalletScreenUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowAskBiometryUseCase.kt similarity index 57% rename from domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSaveWalletScreenUseCase.kt rename to domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowAskBiometryUseCase.kt index 872f62c157..0cab3f56d4 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSaveWalletScreenUseCase.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowAskBiometryUseCase.kt @@ -2,7 +2,7 @@ package com.tangem.domain.settings import com.tangem.domain.settings.repositories.SettingsRepository -class ShouldShowSaveWalletScreenUseCase(private val settingsRepository: SettingsRepository) { +class ShouldShowAskBiometryUseCase(private val settingsRepository: SettingsRepository) { - suspend operator fun invoke(): Boolean = settingsRepository.shouldShowSaveUserWalletScreen() + suspend operator fun invoke(): Boolean = settingsRepository.shouldShowAskBiometry() } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index e39efd87a2..0a8f854790 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -7,9 +7,9 @@ import kotlinx.coroutines.flow.StateFlow @Suppress("TooManyFunctions") interface SettingsRepository { - suspend fun shouldShowSaveUserWalletScreen(): Boolean + suspend fun shouldShowAskBiometry(): Boolean - suspend fun setShouldShowSaveUserWalletScreen(value: Boolean) + suspend fun setShouldShowAskBiometry(value: Boolean) suspend fun isWalletScrollPreviewEnabled(): Boolean diff --git a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt index 195b2c9069..0d53779766 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt @@ -15,7 +15,7 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.settings.SetSaveWalletScreenShownUseCase +import com.tangem.domain.settings.SetAskBiometryShownUseCase import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase @@ -39,7 +39,7 @@ import javax.inject.Inject internal class AskBiometryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, paramsContainer: ParamsContainer, - private val setSaveWalletScreenShownUseCase: SetSaveWalletScreenShownUseCase, + private val setAskBiometryShownUseCase: SetAskBiometryShownUseCase, private val settingsRepository: SettingsRepository, private val tangemSdkManager: TangemSdkManager, private val getSelectedWalletUseCase: GetSelectedWalletUseCase, @@ -66,7 +66,7 @@ internal class AskBiometryModel @Inject constructor( init { modelScope.launch { - setSaveWalletScreenShownUseCase() + setAskBiometryShownUseCase() } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index bfdc9b284b..9ced337ddf 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -48,7 +48,7 @@ internal class UserWalletListModel @Inject constructor( private val userWalletsFetcher = userWalletsFetcherFactory.create( messageSender = messageSender, onlyMultiCurrency = false, - authMode = false, + isAuthMode = false, onWalletClick = { userWalletId -> router.push(AppRoute.WalletSettings(userWalletId)) }, ) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt index 2db69f48ee..a3d0e3c9d7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt @@ -5,12 +5,20 @@ import arrow.core.getOrElse import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.SetAskBiometryShownUseCase +import com.tangem.domain.settings.ShouldShowAskBiometryUseCase import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM +import com.tangem.features.hotwallet.impl.R import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.UnlockHotWallet @@ -19,9 +27,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class AccessCodeModel @Inject constructor( @@ -31,6 +41,10 @@ internal class AccessCodeModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val walletsRepository: WalletsRepository, private val tangemHotSdk: TangemHotSdk, + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, + private val setAskBiometryShownUseCase: SetAskBiometryShownUseCase, + private val canUseBiometryUseCase: CanUseBiometryUseCase, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -87,6 +101,8 @@ internal class AccessCodeModel @Inject constructor( auth = HotAuth.Password(accessCode.toCharArray()), ) + tryToAskForBiometry() + if (walletsRepository.requireAccessCode().not()) { updatedHotWalletId = tangemHotSdk.changeAuth( unlockHotWallet = UnlockHotWallet( @@ -127,4 +143,49 @@ internal class AccessCodeModel @Inject constructor( } } } + + private suspend fun tryToAskForBiometry() { + if (!shouldAskForBiometry()) return + + suspendCancellableCoroutine { continuation -> + uiMessageSender.send( + DialogMessage( + title = resourceReference(R.string.common_attention), + message = resourceReference(R.string.hot_access_code_set_biometric_ask), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_allow), + onClick = { + modelScope.launch { + setAskBiometryShownUseCase() + walletsRepository.setUseBiometricAuthentication(true) + walletsRepository.setRequireAccessCode(false) + continuation.resumeWith(Result.success(Unit)) + } + }, + ), + secondAction = EventMessageAction( + title = resourceReference(R.string.save_user_wallet_agreement_dont_allow), + onClick = { + modelScope.launch { + setAskBiometryShownUseCase() + continuation.resumeWith(Result.success(Unit)) + } + }, + ), + onDismissRequest = { + modelScope.launch { + continuation.resumeWith(Result.success(Unit)) + } + }, + ), + ) + } + } + + private suspend fun shouldAskForBiometry(): Boolean { + val canUseBiometry = canUseBiometryUseCase() + val shouldShowAskBiometry = shouldShowAskBiometryUseCase() + + return canUseBiometry && shouldShowAskBiometry + } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index e1aca5db5e..0ac07b8a3e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -149,7 +149,7 @@ internal class OnboardingEntryModel @Inject constructor( doneMode: OnboardingDoneComponent.Mode = OnboardingDoneComponent.Mode.WalletCreated, ) { modelScope.launch { - if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) { + if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowAskBiometry()) { doIfVisa { analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.BiometricScreenOpened) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 5c62c61337..e05be61546 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -19,8 +19,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.domain.wallets.usecase.UpdateWalletUseCase import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog @@ -37,7 +37,10 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.StringsSigns import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -52,8 +55,8 @@ internal class MultiWalletFinalizeModel @Inject constructor( private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, - private val userWalletsListManager: UserWalletsListManager, private val saveWalletUseCase: SaveWalletUseCase, + private val getUserWalletsUseCase: GetWalletsUseCase, private val updateWalletUseCase: UpdateWalletUseCase, private val cardRepository: CardRepository, private val onboardingRepository: OnboardingRepository, @@ -79,8 +82,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( // sets proper artwork state for initial step // (if we start from backup cards, we need to show proper artwork) ([REDACTED_TASK_KEY]) when (getInitialStep()) { - MultiWalletFinalizeUM.Step.Primary -> { /* state is already set */ - } + MultiWalletFinalizeUM.Step.Primary -> { /* state is already set */ } MultiWalletFinalizeUM.Step.BackupDevice1 -> { onEvent.emit(MultiWalletFinalizeComponent.Event.OneBackupCardAdded) } @@ -245,7 +247,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( userWalletCreated } OnboardingMultiWalletComponent.Mode.AddBackup -> { - val userWallet = userWalletsListManager.userWallets.first() + val userWallet = getUserWalletsUseCase.invokeSync() .firstOrNull { it is UserWallet.Cold && it.scanResponse.primaryCard?.cardId == scanResponse.primaryCard?.cardId diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt index 674541796f..97868f231b 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletsFetcher.kt @@ -14,7 +14,7 @@ interface UserWalletsFetcher { fun create( messageSender: UiMessageSender, onlyMultiCurrency: Boolean, - authMode: Boolean, + isAuthMode: Boolean, onWalletClick: (UserWalletId) -> Unit, ): UserWalletsFetcher } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index b72ac8cf5c..6924fc073a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -10,6 +10,7 @@ import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked @@ -35,6 +36,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener @@ -58,7 +60,7 @@ internal class WalletModel @Inject constructor( private val walletScreenContentLoader: WalletScreenContentLoader, private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val getWalletsUseCase: GetWalletsUseCase, - private val shouldShowSaveWalletScreenUseCase: ShouldShowSaveWalletScreenUseCase, + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, private val setWalletFirstTimeUsageUseCase: SetWalletFirstTimeUsageUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase, @@ -80,6 +82,8 @@ internal class WalletModel @Inject constructor( private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, private val shouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -137,7 +141,7 @@ internal class WalletModel @Inject constructor( modelScope.launch(dispatchers.main) { withContext(dispatchers.io) { delay(timeMillis = 1_800) } - if (isShowSaveWalletScreenEnabled()) { + if (shouldShowAskBiometryBottomSheet()) { innerWalletRouter.dialogNavigation.activate( configuration = WalletDialogConfig.AskForBiometry, ) @@ -159,8 +163,15 @@ internal class WalletModel @Inject constructor( } } - private suspend fun isShowSaveWalletScreenEnabled(): Boolean { - return innerWalletRouter.isWalletLastScreen() && shouldShowSaveWalletScreenUseCase() && canUseBiometryUseCase() + private suspend fun shouldShowAskBiometryBottomSheet(): Boolean { + return if (hotWalletFeatureToggles.isHotWalletEnabled) { + userWalletsListRepository.userWalletsSync().any { it is UserWallet.Cold } && + innerWalletRouter.isWalletLastScreen() && + shouldShowAskBiometryUseCase() && + canUseBiometryUseCase() + } else { + innerWalletRouter.isWalletLastScreen() && shouldShowAskBiometryUseCase() && canUseBiometryUseCase() + } } private fun subscribeToUserWalletsUpdates() = channelFlow { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt index 5c27b29b42..562c8a99a8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt @@ -76,18 +76,19 @@ internal class WalletsUpdateActionResolver @Inject constructor( isAnyWalletNameChanged(state, wallets) -> { getRenameWalletsAction(state, wallets) } - isAnyHotWalletBackedUp(state, wallets) -> { + isAnyHotWalletBackedUpChange(state, wallets) -> { getHotWalletsBackedUpAction(state, wallets) } else -> getUpdateSelectedWalletAction(state, wallets, selectedWallet) } } - private fun isAnyHotWalletBackedUp(state: WalletScreenState, wallets: List): Boolean { + private fun isAnyHotWalletBackedUpChange(state: WalletScreenState, wallets: List): Boolean { val incompleteActivationWalletIds = state.incompleteActivationWalletIds() - return incompleteActivationWalletIds.mapNotNull { walletId -> - wallets.firstOrNull { it.walletId == walletId && it is UserWallet.Hot && it.backedUp } - }.isNotEmpty() + val walletsToUpdate = wallets.filter { + it is UserWallet.Hot && it.backedUp == incompleteActivationWalletIds.contains(it.walletId) + } + return walletsToUpdate.isNotEmpty() } private fun isWalletsCountChanged(state: WalletScreenState, wallets: List): Boolean { @@ -163,8 +164,7 @@ internal class WalletsUpdateActionResolver @Inject constructor( val incompleteActivationWalletIds = state.incompleteActivationWalletIds() val walletsToUpdate = wallets.filter { - it is UserWallet.Hot && it.backedUp && - incompleteActivationWalletIds.contains(it.walletId) + it is UserWallet.Hot && it.backedUp == incompleteActivationWalletIds.contains(it.walletId) } return Action.ReloadWarningsForWallets(walletsToUpdate) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt index 1725952db7..a8c3a91d43 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach internal class MultiWalletWarningsSubscriber( @@ -31,6 +32,11 @@ internal class MultiWalletWarningsSubscriber( .onEach { warnings -> val displayedState = stateHolder.getWalletState(userWallet.walletId) + // Wait until the wallet appears in the list + stateHolder.uiState.first { + it.wallets.any { walletState -> walletState.walletCardState.id == userWallet.walletId } + } + stateHolder.update(SetWarningsTransformer(userWallet.walletId, warnings)) walletWarningsAnalyticsSender.send(displayedState, warnings) walletWarningsSingleEventSender.send( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt index 6a310e5c31..1b4e9c358f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt @@ -43,7 +43,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( @Assisted private val onWalletClick: (UserWalletId) -> Unit, @Assisted private val messageSender: UiMessageSender, @Assisted("onlyMultiCurrency") private val onlyMultiCurrency: Boolean, - @Assisted("authMode") private val authMode: Boolean, + @Assisted("isAuthMode") private val isAuthMode: Boolean, private val userWalletImageFetcher: UserWalletImageFetcher, dispatchers: CoroutineDispatcherProvider, ) : UserWalletsFetcher { @@ -55,7 +55,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( override val userWallets: Flow> = walletsFlow.transformLatest { wallets -> val uiModels = UserWalletItemUMConverter( onClick = onWalletClick, - authMode = authMode, + isAuthMode = isAuthMode, ).convertList(wallets) .toImmutableList() @@ -64,7 +64,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( combine( flow = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getBalanceHidingSettingsUseCase().distinctUntilChanged(), - flow3 = getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)).distinctUntilChanged(), + flow3 = getTotalBalanceFlow(wallets), flow4 = userWalletImageFetcher.walletsImage(wallets, ArtworkSize.SMALL), ) { maybeAppCurrency, balanceHidingSettings, maybeBalances, artworks -> createUiModels( @@ -88,6 +88,19 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( } .flowOn(dispatchers.default) + private fun getTotalBalanceFlow( + wallets: List, + ): Flow>> { + val walletIds = wallets.map(UserWallet::walletId) + + return if (isAuthMode) { + // We should not load balances in auth mode + flowOf(Lce.Loading(walletIds.associateWith { TotalFiatBalance.Loading })) + } else { + getWalletTotalBalanceUseCase(walletIds).distinctUntilChanged() + } + } + private fun createUiModels( wallets: List, maybeAppCurrency: Either, @@ -117,7 +130,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( balance = balance, isBalanceHidden = balanceHidingSettings.isBalanceHidden, artwork = artworks[userWallet.walletId], - authMode = authMode, + isAuthMode = isAuthMode, ) .convert(userWallet) } @@ -136,7 +149,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( override fun create( messageSender: UiMessageSender, @Assisted("onlyMultiCurrency") onlyMultiCurrency: Boolean, - @Assisted("authMode") authMode: Boolean, + @Assisted("isAuthMode") isAuthMode: Boolean, onWalletClick: (UserWalletId) -> Unit, ): DefaultUserWalletsFetcher } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt index 727e85c485..dc19a55e4e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/utils/WcUserWalletsFetcher.kt @@ -29,7 +29,7 @@ internal class WcUserWalletsFetcher( private val userWalletsFetcher = userWalletsFetcherFactory.create( messageSender = messageSender, onlyMultiCurrency = true, - authMode = false, + isAuthMode = false, onWalletClick = { onWalletSelected(it) }, ) diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index 3baa86b20e..d1128d02f2 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -55,7 +55,7 @@ internal class WelcomeModel @Inject constructor( private val walletsFetcher = userWalletsFetcherFactory.create( messageSender = uiMessageSender, onlyMultiCurrency = false, - authMode = true, + isAuthMode = true, onWalletClick = { walletId -> modelScope.launch { val userWallets = userWalletsListRepository.userWalletsSync() From 96ce2fc718e3bbeab28d59f11ff31b8ce1b6699a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Sep 2025 14:58:00 +0200 Subject: [PATCH 055/132] Updated on 2026-08-14 --- .../tap/di/domain/OnrampDomainModule.kt | 34 +- core/res/src/main/res/values/strings.xml | 5 +- .../src/main/res/drawable/ic_best_rate_16.xml | 9 + .../src/main/res/drawable/ic_fastest_16.xml | 10 + .../tangem/domain/onramp/model/OnrampOffer.kt | 5 +- .../onramp/GetOnrampAllOffersUseCase.kt | 5 +- .../domain/onramp/GetOnrampOffersUseCase.kt | 46 ++- .../domain/onramp/utils/OnrampOfferUtils.kt | 8 + .../onramp/alloffers/AllOffersComponent.kt | 17 + .../alloffers/DefaultAllOffersComponent.kt | 31 ++ .../di/AllOffersComponentModelModule.kt | 20 + .../alloffers/di/AllOffersComponentModule.kt | 18 + .../onramp/alloffers/model/AllOffersModel.kt | 19 + .../mainv2/DefaultOnrampV2MainComponent.kt | 10 + .../DefaultOnrampV2MainFeatureToggle.kt | 2 +- .../mainv2/entity/OnrampOfferBlockUM.kt | 23 +- .../entity/OnrampV2MainBottomSheetConfig.kt | 3 + .../factory/OnrampOffersStateFactory.kt | 106 ++++++ .../model/OnrampV2MainComponentModel.kt | 38 +- .../onramp/mainv2/ui/OnrampOffersContent.kt | 357 +++++++++++++++++- .../onramp/mainv2/ui/OnrampV2AmountContent.kt | 13 +- .../onramp/root/DefaultOnrampComponent.kt | 62 ++- 22 files changed, 784 insertions(+), 57 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_best_rate_16.xml create mode 100644 core/ui/src/main/res/drawable/ic_fastest_16.xml create mode 100644 domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModelModule.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModule.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index f5c4fda538..18f3540843 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -1,11 +1,7 @@ package com.tangem.tap.di.domain -import com.tangem.domain.onramp.repositories.LegacyTopUpRepository import com.tangem.domain.onramp.* -import com.tangem.domain.onramp.repositories.HotCryptoRepository -import com.tangem.domain.onramp.repositories.OnrampErrorResolver -import com.tangem.domain.onramp.repositories.OnrampRepository -import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import com.tangem.domain.onramp.repositories.* import com.tangem.domain.settings.repositories.SettingsRepository import dagger.Module import dagger.Provides @@ -237,4 +233,32 @@ internal object OnrampDomainModule { fun provideGetLegacyTopUpUrlUseCase(legacyTopUpRepository: LegacyTopUpRepository): GetLegacyTopUpUrlUseCase { return GetLegacyTopUpUrlUseCase(legacyTopUpRepository) } + + @Provides + @Singleton + fun provideGetOnrampAllOffersUseCase( + onrampRepository: OnrampRepository, + onrampErrorResolver: OnrampErrorResolver, + settingsRepository: SettingsRepository, + ): GetOnrampAllOffersUseCase { + return GetOnrampAllOffersUseCase( + onrampRepository = onrampRepository, + errorResolver = onrampErrorResolver, + settingsRepository = settingsRepository, + ) + } + + @Provides + @Singleton + fun provideGetOnrampOffersUseCase( + onrampRepository: OnrampRepository, + onrampErrorResolver: OnrampErrorResolver, + onrampTransactionRepository: OnrampTransactionRepository, + ): GetOnrampOffersUseCase { + return GetOnrampOffersUseCase( + onrampRepository = onrampRepository, + errorResolver = onrampErrorResolver, + onrampTransactionRepository = onrampTransactionRepository, + ) + } } \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 7d747732ea..b475fe441f 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -876,7 +876,10 @@ Payment method Available up to %s Available from %s - %d providers + + %d provider + %d providers + Providers Recently used Recommended diff --git a/core/ui/src/main/res/drawable/ic_best_rate_16.xml b/core/ui/src/main/res/drawable/ic_best_rate_16.xml new file mode 100644 index 0000000000..7e36b66936 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_best_rate_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_fastest_16.xml b/core/ui/src/main/res/drawable/ic_fastest_16.xml new file mode 100644 index 0000000000..072444ff1d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_fastest_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt index e116e55735..56700b01f4 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt @@ -1,13 +1,16 @@ package com.tangem.domain.onramp.model +import java.math.BigDecimal + data class OnrampOffersBlock( val category: OnrampOfferCategory, val offers: List, - val isVisible: Boolean = offers.isNotEmpty(), + val hasMoreOffers: Boolean, ) data class OnrampOffer( val quote: OnrampQuote, + val rateDif: BigDecimal?, val advantages: OnrampOfferAdvantages = OnrampOfferAdvantages.Default, ) diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt index 6cc1247867..f2f787e182 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt @@ -12,6 +12,7 @@ import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository +import com.tangem.domain.onramp.utils.calculateRateDif import com.tangem.domain.settings.repositories.SettingsRepository import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -38,6 +39,7 @@ class GetOnrampAllOffersUseCase( val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() val overallBestRateQuote = validQuotes.maxByOrNull { it.toAmount.value } + val bestRate = overallBestRateQuote?.toAmount?.value val offersByPaymentMethod = validQuotes.groupBy { it.paymentMethod } @@ -48,7 +50,8 @@ class GetOnrampAllOffersUseCase( } else { OnrampOfferAdvantages.Default } - OnrampOffer(quote = quote, advantages = advantages) + val rateDif = calculateRateDif(quote.toAmount.value, bestRate) + OnrampOffer(quote = quote, rateDif = rateDif, advantages = advantages) } val groupBestRateOfferData = methodQuotes.maxByOrNull { it.toAmount.value } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt index 29041336a9..1632abd3cc 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt @@ -11,6 +11,7 @@ import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import com.tangem.domain.onramp.utils.calculateRateDif import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -43,8 +44,12 @@ class GetOnrampOffersUseCase( val validQuotes = quotes.filterIsInstance() if (validQuotes.isEmpty()) return emptyList() + val bestRateQuote = validQuotes.maxByOrNull { it.toAmount.value } + val bestRate = bestRateQuote?.toAmount?.value + val offers = validQuotes.map { quote -> - OnrampOffer(quote = quote) + val rateDif = calculateRateDif(quote.toAmount.value, bestRate) + OnrampOffer(quote = quote, rateDif = rateDif) } val recentOffer = findRecentOffer(offers, transactions) @@ -107,6 +112,15 @@ class GetOnrampOffersUseCase( fastestOffer: OnrampOffer?, allOffers: List, ): List { + val recommendedOffers = buildRecommendedOffers( + recentOffer = recentOffer, + bestRateOffer = bestRateOffer, + fastestOffer = fastestOffer, + ) + + val shownOffersCount = (if (recentOffer != null) 1 else 0) + recommendedOffers.size + val hasMoreOffers = allOffers.size > shownOffersCount + return buildList { if (recentOffer != null) { add( @@ -119,23 +133,20 @@ class GetOnrampOffersUseCase( bestRateOffer, fastestOffer, ), + rateDif = if (bestRateOffer != null) recentOffer.rateDif else null, ), ), + hasMoreOffers = false, ), ) } - val recommendedOffers = buildRecommendedOffers( - recentOffer = recentOffer, - bestRateOffer = bestRateOffer, - fastestOffer = fastestOffer, - ) - if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) { add( OnrampOffersBlock( category = OnrampOfferCategory.Recommended, offers = recommendedOffers, + hasMoreOffers = hasMoreOffers, ), ) } @@ -167,17 +178,32 @@ class GetOnrampOffersUseCase( return buildList { if (isSameOffer(bestRateOffer, fastestOffer)) { bestRateOffer?.let { offer -> - add(offer.copy(advantages = OnrampOfferAdvantages.BestRate)) + add( + offer.copy( + advantages = OnrampOfferAdvantages.BestRate, + rateDif = null, + ), + ) } } else { if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) { - add(bestRateOffer.copy(advantages = OnrampOfferAdvantages.BestRate)) + add( + bestRateOffer.copy( + advantages = OnrampOfferAdvantages.BestRate, + rateDif = null, + ), + ) } if (fastestOffer != null && !isSameOffer(fastestOffer, recentOffer) && !isSameOffer(fastestOffer, bestRateOffer) ) { - add(fastestOffer.copy(advantages = OnrampOfferAdvantages.Fastest)) + add( + fastestOffer.copy( + advantages = OnrampOfferAdvantages.Fastest, + rateDif = if (bestRateOffer != null) fastestOffer.rateDif else null, + ), + ) } } } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt new file mode 100644 index 0000000000..0b3625d494 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.onramp.utils + +import java.math.BigDecimal + +internal fun calculateRateDif(currentTokenRate: BigDecimal, bestRate: BigDecimal?): BigDecimal? { + if (bestRate == null) return null + return BigDecimal.ONE - currentTokenRate / bestRate +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt new file mode 100644 index 0000000000..7c2de6da52 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt @@ -0,0 +1,17 @@ +package com.tangem.features.onramp.alloffers + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet + +internal interface AllOffersComponent : ComposableBottomSheetComponent { + + data class Params( + val userWallet: UserWallet, + val cryptoCurrency: CryptoCurrency, + val onDismiss: () -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt new file mode 100644 index 0000000000..02adaaa504 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt @@ -0,0 +1,31 @@ +package com.tangem.features.onramp.alloffers + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.onramp.alloffers.model.AllOffersModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAllOffersComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: AllOffersComponent.Params, +) : AllOffersComponent, AppComponentContext by context { + + private val model: AllOffersModel = getOrCreateModel(params) + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + // TODO to be continued in [REDACTED_TASK_KEY] + } + + @AssistedFactory + interface Factory : AllOffersComponent.Factory { + override fun create(context: AppComponentContext, params: AllOffersComponent.Params): DefaultAllOffersComponent + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModelModule.kt new file mode 100644 index 0000000000..e709ab73d6 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onramp.alloffers.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onramp.alloffers.model.AllOffersModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AllOffersComponentModelModule { + + @Binds + @IntoMap + @ClassKey(AllOffersModel::class) + fun bindAllOffersModel(model: AllOffersModel): Model +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModule.kt new file mode 100644 index 0000000000..3979f343c2 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/di/AllOffersComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.onramp.alloffers.di + +import com.tangem.features.onramp.alloffers.AllOffersComponent +import com.tangem.features.onramp.alloffers.DefaultAllOffersComponent +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 AllOffersComponentModule { + + @Binds + @Singleton + fun bindAllOffersComponentFactory(factory: DefaultAllOffersComponent.Factory): AllOffersComponent.Factory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt new file mode 100644 index 0000000000..3280c17cb9 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -0,0 +1,19 @@ +package com.tangem.features.onramp.alloffers.model + +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.onramp.alloffers.AllOffersComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +internal class AllOffersModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: AllOffersComponent.Params = paramsContainer.require() + + fun dismiss() { + params.onDismiss() + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt index a651416d7f..2487bf37b1 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt @@ -12,6 +12,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.onramp.alloffers.AllOffersComponent import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent import com.tangem.features.onramp.mainv2.entity.OnrampV2MainBottomSheetConfig import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel @@ -26,6 +27,7 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor( @Assisted private val params: OnrampV2MainComponent.Params, private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory, private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory, + private val allOffersComponentFactory: AllOffersComponent.Factory, ) : OnrampV2MainComponent, AppComponentContext by appComponentContext { private val model: OnrampV2MainComponentModel = getOrCreateModel(params) @@ -67,6 +69,14 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor( onDismiss = model.bottomSheetNavigation::dismiss, ), ) + OnrampV2MainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create( + context = childByContext(componentContext), + params = AllOffersComponent.Params( + userWallet = model.userWallet, + cryptoCurrency = params.cryptoCurrency, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt index 256bead28d..815fa5060b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt @@ -6,5 +6,5 @@ class DefaultOnrampV2MainFeatureToggle( private val featureTogglesManager: FeatureTogglesManager, ) : OnrampV2MainFeatureToggle { override val isOnrampNewMainEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_RECEIVE_ENABLED") + get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_MAIN_ENABLED") } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt index 583cc962bb..5e41b49346 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.mainv2.entity import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.onramp.model.OnrampPaymentMethod import kotlinx.collections.immutable.ImmutableList @@ -20,22 +21,32 @@ internal sealed interface OnrampOffersBlockUM { data class Content( override val isBlockVisible: Boolean, - val offers: ImmutableList, + val recentOffer: OnrampOfferUM?, + val recommended: ImmutableList, + val onrampAllOffersButtonConfig: OnrampAllOffersButtonConfig?, ) : OnrampOffersBlockUM } internal data class OnrampOfferUM( - val category: OnrampOfferCategory, - val advantages: OnrampOfferAdvantages, + val category: OnrampOfferCategoryUM, + val advantages: OnrampOfferAdvantagesUM, val paymentMethod: OnrampPaymentMethod, val providerId: String, val providerName: String, + val rate: String, + val diff: TextReference?, + val onBuyClicked: () -> Unit, ) -internal enum class OnrampOfferCategory { +internal enum class OnrampOfferCategoryUM { RecentlyUsed, Recommended } -internal enum class OnrampOfferAdvantages { +internal enum class OnrampOfferAdvantagesUM { Default, BestRate, Fastest -} \ No newline at end of file +} + +internal data class OnrampAllOffersButtonConfig( + val title: TextReference, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt index dcb21bbf8a..d9e6c47a2d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt @@ -10,4 +10,7 @@ sealed interface OnrampV2MainBottomSheetConfig { @Serializable data object CurrenciesList : OnrampV2MainBottomSheetConfig + + @Serializable + data object AllOffers : OnrampV2MainBottomSheetConfig } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt new file mode 100644 index 0000000000..377c5987d4 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt @@ -0,0 +1,106 @@ +package com.tangem.features.onramp.mainv2.entity.factory + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.onramp.model.* +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.main.entity.OnrampIntents +import com.tangem.features.onramp.mainv2.entity.* +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns.MINUS +import kotlinx.collections.immutable.toPersistentList + +internal class OnrampOffersStateFactory( + private val currentStateProvider: Provider, + private val onrampIntents: OnrampIntents, +) { + + fun getOnShowOffersState(offers: List): OnrampV2MainComponentUM { + val currentState = currentStateProvider.invoke() + return when (currentState) { + is OnrampV2MainComponentUM.Content -> { + currentState.copy( + offersBlockState = mapOnrampOffersBlockToUM(offers), + ) + } + is OnrampV2MainComponentUM.InitialLoading -> { + currentState + } + } + } + + private fun mapOnrampOffersBlockToUM(offersBlocks: List): OnrampOffersBlockUM.Content { + val allOffersUM = mutableListOf() + offersBlocks.map { block -> + block.offers.forEach { offer -> + when (val currentQuote = offer.quote) { + is OnrampQuote.AmountError, + is OnrampQuote.Error, + -> Unit + is OnrampQuote.Data -> { + allOffersUM.add( + OnrampOfferUM( + category = mapOfferCategoryDTOtoUM(block.category), + advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), + paymentMethod = currentQuote.paymentMethod, + providerId = currentQuote.provider.id, + providerName = currentQuote.provider.info.name, + rate = currentQuote.toAmount.value.format { + crypto( + symbol = currentQuote.toAmount.symbol, + decimals = currentQuote.toAmount.decimals, + ) + }, + diff = offer.rateDif?.let { diff -> + stringReference("$MINUS${diff.format { percent() }}") + }, + onBuyClicked = { + onrampIntents.onBuyClick( + quote = OnrampProviderWithQuote.Data( + provider = offer.quote.provider, + paymentMethod = currentQuote.paymentMethod, + toAmount = currentQuote.toAmount, + fromAmount = currentQuote.fromAmount, + ), + ) + }, + ), + ) + } + } + } + } + + return OnrampOffersBlockUM.Content( + isBlockVisible = false, + recentOffer = allOffersUM.firstOrNull { it.category == OnrampOfferCategoryUM.RecentlyUsed }, + recommended = allOffersUM.filter { it.category == OnrampOfferCategoryUM.Recommended }.toPersistentList(), + onrampAllOffersButtonConfig = if (offersBlocks.any { it.hasMoreOffers }) { + OnrampAllOffersButtonConfig( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + onClick = { onrampIntents.openProviders() }, + ) + } else { + null + }, + ) + } + + private fun mapOfferCategoryDTOtoUM(category: OnrampOfferCategory): OnrampOfferCategoryUM { + return when (category) { + OnrampOfferCategory.Recent -> OnrampOfferCategoryUM.RecentlyUsed + OnrampOfferCategory.Recommended -> OnrampOfferCategoryUM.Recommended + } + } + + private fun mapOfferAdvantagesDTOtoUM(advantages: OnrampOfferAdvantages): OnrampOfferAdvantagesUM { + return when (advantages) { + OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default + OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate + OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest + } + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt index 8d6615a675..d83a94d1f0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -20,6 +20,7 @@ import com.tangem.features.onramp.main.entity.OnrampLastUpdate import com.tangem.features.onramp.mainv2.OnrampV2MainComponent import com.tangem.features.onramp.mainv2.entity.* import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory +import com.tangem.features.onramp.mainv2.entity.factory.OnrampOffersStateFactory import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2AmountStateFactory import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2StateFactory import com.tangem.features.onramp.utils.sendOnrampErrorEvent @@ -32,7 +33,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class OnrampV2MainComponentModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, @@ -44,6 +45,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase, private val fetchPairsUseCase: OnrampFetchPairsUseCase, private val amountInputManager: InputManager, + private val getOnrampOffersUseCase: GetOnrampOffersUseCase, paramsContainer: ParamsContainer, getWalletsUseCase: GetWalletsUseCase, ) : Model(), OnrampIntents { @@ -58,6 +60,13 @@ internal class OnrampV2MainComponentModel @Inject constructor( OnrampAmountButtonUMStateFactory() } + private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampOffersStateFactory( + currentStateProvider = Provider { _state.value }, + onrampIntents = this, + ) + } + private val stateFactory = OnrampV2StateFactory( currentStateProvider = Provider { _state.value }, cryptoCurrency = params.cryptoCurrency, @@ -94,6 +103,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( subscribeToAmountChanges() subscribeToCountryAndCurrencyUpdates() subscribeToQuotesUpdate() + subscribeOnOffers() } override fun onDestroy() { @@ -117,11 +127,19 @@ internal class OnrampV2MainComponentModel @Inject constructor( } override fun onBuyClick(quote: OnrampProviderWithQuote.Data) { - // TODO in [REDACTED_TASK_KEY] to be continued + val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return + analyticsEventHandler.send( + OnrampAnalyticsEvent.OnBuyClick( + providerName = quote.provider.info.name, + currency = currentContentState.amountBlockState.currencyUM.code, + tokenSymbol = params.cryptoCurrency.symbol, + ), + ) + params.openRedirectPage(quote) } override fun openProviders() { - // TODO in [REDACTED_TASK_KEY] to be continued + bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.AllOffers) } override fun onRefresh() { @@ -172,6 +190,20 @@ internal class OnrampV2MainComponentModel @Inject constructor( router.pop() } + private fun subscribeOnOffers() = modelScope.launch { + getOnrampOffersUseCase.invoke( + userWalletId = userWallet.walletId, + cryptoCurrencyId = params.cryptoCurrency.id, + ).collectLatest { maybeOffers -> + maybeOffers.fold( + ifLeft = ::handleOnrampError, + ifRight = { offers -> + _state.update { onrampOffersStateFactory.getOnShowOffersState(offers) } + }, + ) + } + } + private fun subscribeToAmountChanges() = modelScope.launch { amountInputManager.query .filter(String::isNotEmpty) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt index 6b35933d9e..1fc602096c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt @@ -1,13 +1,42 @@ package com.tangem.features.onramp.mainv2.ui +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM +import kotlinx.collections.immutable.persistentListOf @Composable internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { @@ -23,10 +52,330 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { ), label = "Offers block animation", ) { - Text( - text = "Some offers", - style = TangemTheme.typography.head, + if (state is OnrampOffersBlockUM.Content) { + Column(modifier = Modifier.fillMaxWidth()) { + state.recentOffer?.let { recentOffer -> + Column { + Text( + modifier = Modifier.padding(start = 12.dp), + text = stringResourceSafe(R.string.onramp_recently_used_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerH(8.dp) + + Offer(recentOffer) + + SpacerH(16.dp) + } + } + + Text( + modifier = Modifier.padding(start = 12.dp), + text = stringResourceSafe(R.string.onramp_recommended_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerH(8.dp) + + state.recommended.fastForEach { + Offer(it) + SpacerH(8.dp) + } + + state.onrampAllOffersButtonConfig?.let { + SpacerH(12.dp) + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = it.title.resolveReference(), + onClick = it.onClick, + ) + } + } + } + } +} + +@Composable +private fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = RoundedCornerShape(14.dp), + ) + .padding(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + OfferHeader(advantage = onrampOfferUM.advantages) + RateBlock(rate = onrampOfferUM.rate, diff = onrampOfferUM.diff) + } + SpacerWMax() + SecondaryButton( + size = TangemButtonSize.RoundedAction, + text = stringResourceSafe(R.string.common_buy), + onClick = onrampOfferUM.onBuyClicked, + ) + } + SpacerH(10.dp) + HorizontalDivider(color = TangemTheme.colors.stroke.primary) + SpacerH(12.dp) + PaymentBlockInOffer( + paymentMethod = onrampOfferUM.paymentMethod, + providerName = onrampOfferUM.providerName, ) - // TODO in [REDACTED_TASK_KEY] to be continued + } +} + +@Composable +private fun OfferHeader(advantage: OnrampOfferAdvantagesUM) { + when (advantage) { + OnrampOfferAdvantagesUM.Default -> { + Text( + text = stringResourceSafe(R.string.onramp_title_you_get), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + OnrampOfferAdvantagesUM.BestRate -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_best_rate_16), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.express_provider_best_rate), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + ) + } + } + OnrampOfferAdvantagesUM.Fastest -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_fastest_16), + tint = TangemTheme.colors.icon.attention, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.onramp_offer_type_fastet), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.attention, + ) + } + } + } +} + +@Composable +private fun RateBlock(rate: String, diff: TextReference?) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = rate, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + diff?.let { + Text( + modifier = Modifier + .background( + color = TangemTheme.colors.text.warning.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 4.dp), + text = it.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.warning, + ) + } + } +} + +@Composable +private fun PaymentBlockInOffer(paymentMethod: OnrampPaymentMethod, providerName: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + + SpacerW(2.dp) + + TimingBlock(speed = paymentMethod.type.getProcessingSpeed()) + + SpacerW(6.dp) + + DrawDot(color = TangemTheme.colors.text.tertiary) + + SpacerW(6.dp) + + Text( + text = providerName, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerWMax() + + Text( + text = stringResourceSafe(R.string.onramp_pay_with), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerW(4.dp) + + SubcomposeAsyncImage( + modifier = Modifier.sizeIn(maxWidth = 38.dp, maxHeight = 16.dp), + model = ImageRequest.Builder(context = LocalContext.current) + .data(paymentMethod.imageUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + loading = { + TextShimmer( + style = TangemTheme.typography.body1, + modifier = Modifier.width(40.dp), + ) + }, + contentDescription = null, + ) + } +} + +@Composable +private fun TimingBlock(speed: PaymentMethodType.PaymentSpeed) { + val timingText = when (speed) { + PaymentMethodType.PaymentSpeed.Instant -> { + stringResourceSafe(id = R.string.onramp_instant_status) + } + PaymentMethodType.PaymentSpeed.FewMin -> { + stringResourceSafe( + id = R.string.onramp_timing_minutes, + FEW_MINS_VALUE, + ) + } + PaymentMethodType.PaymentSpeed.FewDays -> { + pluralStringResourceSafe( + id = R.plurals.onramp_timing_days, + count = FEW_DAYS_VALUE, + FEW_DAYS_VALUE, + ) + } + PaymentMethodType.PaymentSpeed.PlentyDays -> { + pluralStringResourceSafe( + id = R.plurals.onramp_timing_days, + count = PLENTY_DAYS_VALUE, + PLENTY_DAYS_VALUE, + ) + } + PaymentMethodType.PaymentSpeed.Unknown -> "" + } + + Text( + text = timingText, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +fun DrawDot(color: Color) { + Spacer( + modifier = Modifier + .size(4.dp) + .drawWithCache { + val radius = size.minDimension / 2f + onDrawBehind { + drawCircle( + color = color, + radius = radius, + ) + } + }, + ) +} + +// will be hardcoded till server will be ready to provide this values +private const val FEW_MINS_VALUE = "3-5" +private const val FEW_DAYS_VALUE = 3 +private const val PLENTY_DAYS_VALUE = 5 + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun OnrampOffersContentPreview() { + val state = OnrampOffersBlockUM.Content( + isBlockVisible = true, + recentOffer = OnrampOfferUM( + category = OnrampOfferCategoryUM.RecentlyUsed, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId3", + providerName = "Simplex", + rate = "0,00045334 BTC", + diff = stringReference("–27%"), + onBuyClicked = {}, + ), + recommended = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Fastest, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + onrampAllOffersButtonConfig = null, + ) + TangemThemePreview { + OnrampOffersContent(state) } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt index 284e2e04cc..b0ea37b98b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt @@ -1,5 +1,6 @@ package com.tangem.features.onramp.mainv2.ui +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -51,6 +52,7 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif Column( modifier = modifier + .fillMaxWidth() .background( color = TangemTheme.colors.background.action, shape = RoundedCornerShape(size = TangemTheme.dimens.radius16), @@ -61,16 +63,14 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif ) { OnrampHeaderTitle() - SpacerH(12.dp) - OnrampAmountField( amountField = state.amountBlockState.amountFieldModel, currencyCode = state.amountBlockState.currencyUM.code, ) - SpacerH(8.dp) - - OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel) + AnimatedVisibility(!state.offersBlockState.isBlockVisible) { + OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel) + } SpacerH(20.dp) @@ -115,7 +115,8 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin modifier = Modifier .focusRequester(requester) .padding( - top = TangemTheme.dimens.spacing24, + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing4, start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt index e9ee2b3295..b9ff5cb77a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt @@ -17,6 +17,8 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.onramp.component.OnrampComponent import com.tangem.features.onramp.main.OnrampMainComponent +import com.tangem.features.onramp.mainv2.OnrampV2MainComponent +import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle import com.tangem.features.onramp.redirect.OnrampRedirectComponent import com.tangem.features.onramp.root.entity.OnrampChild import com.tangem.features.onramp.settings.OnrampSettingsComponent @@ -24,13 +26,15 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("UnusedPrivateMember") +@Suppress("LongParameterList") internal class DefaultOnrampComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: OnrampComponent.Params, private val settingsComponentFactory: OnrampSettingsComponent.Factory, private val onrampMainComponentFactory: OnrampMainComponent.Factory, + private val onrampMainV2ComponentFactory: OnrampV2MainComponent.Factory, private val onrampRedirectComponentFactory: OnrampRedirectComponent.Factory, + private val onrampV2MainFeatureToggle: OnrampV2MainFeatureToggle, ) : OnrampComponent, AppComponentContext by context { private val navigation = StackNavigation() @@ -66,24 +70,44 @@ internal class DefaultOnrampComponent @AssistedInject constructor( onBack = navigation::pop, ), ) - OnrampChild.Main -> onrampMainComponentFactory.create( - context = childByContext(componentContext), - params = OnrampMainComponent.Params( - userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, - openSettings = { navigation.push(OnrampChild.Settings) }, - source = params.source, - openRedirectPage = { - navigation.push( - OnrampChild.RedirectPage( - quote = it, - cryptoCurrency = params.cryptoCurrency, - ), - ) - }, - launchSepa = params.launchSepa, - ), - ) + OnrampChild.Main -> if (onrampV2MainFeatureToggle.isOnrampNewMainEnabled) { + onrampMainV2ComponentFactory.create( + context = childByContext(componentContext), + params = OnrampV2MainComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + openSettings = { navigation.push(OnrampChild.Settings) }, + source = params.source, + openRedirectPage = { + navigation.push( + OnrampChild.RedirectPage( + quote = it, + cryptoCurrency = params.cryptoCurrency, + ), + ) + }, + ), + ) + } else { + onrampMainComponentFactory.create( + context = childByContext(componentContext), + params = OnrampMainComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + openSettings = { navigation.push(OnrampChild.Settings) }, + source = params.source, + openRedirectPage = { + navigation.push( + OnrampChild.RedirectPage( + quote = it, + cryptoCurrency = params.cryptoCurrency, + ), + ) + }, + launchSepa = params.launchSepa, + ), + ) + } is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create( context = childByContext(componentContext), params = OnrampRedirectComponent.Params( From fd7bc522f23bd18f88258172a2c18040c91b6e15 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Sep 2025 17:22:08 +0400 Subject: [PATCH 056/132] Updated on 2026-08-14 --- app/build.gradle.kts | 2 ++ app/src/main/AndroidManifest.xml | 10 ++++++ .../tangem/tap/routing/utils/ChildFactory.kt | 9 +++++ .../tap/routing/utils/DeepLinkFactory.kt | 3 ++ .../com/tangem/common/routing/AppRoute.kt | 5 +++ .../tangem/common/routing/DeepLinkRoute.kt | 4 +++ features/tangempay/onboarding/api/.gitignore | 1 + .../tangempay/onboarding/api/build.gradle.kts | 18 ++++++++++ .../TangemPayOnboardingComponent.kt | 13 +++++++ .../deeplink/OnboardVisaDeepLinkHandler.kt | 10 ++++++ features/tangempay/onboarding/impl/.gitignore | 1 + .../onboarding/impl/build.gradle.kts | 34 ++++++++++++++++++ .../DefaultTangemPayOnboardingComponent.kt | 35 +++++++++++++++++++ .../DefaultOnboardVisaDeepLinkHandler.kt | 23 ++++++++++++ .../tangempay/di/TangemPayDeeplinkModule.kt | 18 ++++++++++ .../di/TangemPayOnboardingFeatureModule.kt | 16 +++++++++ .../di/TangemPayOnboardingModelsModule.kt | 20 +++++++++++ .../model/TangemPayOnboardingModel.kt | 28 +++++++++++++++ .../tangempay/ui/TandemPayOnboardingScreen.kt | 31 ++++++++++++++++ .../ui/TangemPayOnboardingScreenState.kt | 3 ++ settings.gradle.kts | 6 +++- 21 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 features/tangempay/onboarding/api/.gitignore create mode 100644 features/tangempay/onboarding/api/build.gradle.kts create mode 100644 features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt create mode 100644 features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/OnboardVisaDeepLinkHandler.kt create mode 100644 features/tangempay/onboarding/impl/.gitignore create mode 100644 features/tangempay/onboarding/impl/build.gradle.kts create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingFeatureModule.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingModelsModule.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5fdb2e580..3458dfc6a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -270,6 +270,8 @@ dependencies { implementation(projects.features.tangempay.details.impl) implementation(projects.features.tangempay.main.api) implementation(projects.features.tangempay.main.impl) + implementation(projects.features.tangempay.onboarding.api) + implementation(projects.features.tangempay.onboarding.impl) implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.impl) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index baf6ae4211..87d3840efe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -264,6 +264,16 @@ android:host="promo" android:scheme="tangem" /> + + + + + + + + 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 9525c29fa0..653b45d4c1 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 @@ -55,6 +55,7 @@ import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent import com.tangem.tap.features.welcome.component.WelcomeComponent import com.tangem.tap.routing.component.RoutingComponent.Child +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject import com.tangem.features.walletconnect.components.WalletConnectEntryComponent as RedesignedWalletConnectComponent @@ -114,6 +115,7 @@ internal class ChildFactory @Inject constructor( private val sendWithSwapComponentFactory: SendWithSwapComponent.Factory, private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory, + private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val walletConnectFeatureToggles: WalletConnectFeatureToggles, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { @@ -606,6 +608,13 @@ internal class ChildFactory @Inject constructor( componentFactory = tangemPayDetailsComponentFactory, ) } + is AppRoute.TangemPayOnboarding -> { + createComponentChild( + context = context, + params = TangemPayOnboardingComponent.Params(route.deeplink), + componentFactory = tangemPayOnboardingComponentFactory, + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 3412cac097..45a9e27528 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -14,6 +14,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler +import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -48,6 +49,7 @@ internal class DeepLinkFactory @Inject constructor( private val sellDeepLink: SellDeepLinkHandler.Factory, private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, + private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -135,6 +137,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Swap.host -> swapDeepLink.create() DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) else -> { Timber.i( """ 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 06cede4e5a..96fcdbc5fd 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 @@ -375,4 +375,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class TangemPayDetails(val userWalletId: UserWalletId) : AppRoute(path = "/tangem_pay_details") + + @Serializable + data class TangemPayOnboarding( + val deeplink: String, + ) : AppRoute(path = "/tangem_pay_onboarding/$deeplink") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index 1fbffcb8a8..9b5ba24bfa 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -59,6 +59,10 @@ sealed class DeepLinkRoute { data object Promo : DeepLinkRoute() { override val host: String = "promo" } + + data object OnboardVisa : DeepLinkRoute() { + override val host: String = "onboard-visa" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/features/tangempay/onboarding/api/.gitignore b/features/tangempay/onboarding/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/tangempay/onboarding/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/tangempay/onboarding/api/build.gradle.kts b/features/tangempay/onboarding/api/build.gradle.kts new file mode 100644 index 0000000000..a52e5bbccd --- /dev/null +++ b/features/tangempay/onboarding/api/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.tangempay.onboarding.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt new file mode 100644 index 0000000000..d2257362fb --- /dev/null +++ b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt @@ -0,0 +1,13 @@ +package com.tangem.features.tangempay.components + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface TangemPayOnboardingComponent : ComposableContentComponent { + + data class Params( + val deeplink: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/OnboardVisaDeepLinkHandler.kt b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/OnboardVisaDeepLinkHandler.kt new file mode 100644 index 0000000000..eb9bd4ac28 --- /dev/null +++ b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/OnboardVisaDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.deeplink + +import android.net.Uri + +interface OnboardVisaDeepLinkHandler { + + interface Factory { + fun create(uri: Uri): OnboardVisaDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/.gitignore b/features/tangempay/onboarding/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/tangempay/onboarding/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/tangempay/onboarding/impl/build.gradle.kts b/features/tangempay/onboarding/impl/build.gradle.kts new file mode 100644 index 0000000000..ab2de5b977 --- /dev/null +++ b/features/tangempay/onboarding/impl/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.tangempay.onboarding.impl" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Common */ + implementation(projects.common.routing) + + /** Features api */ + implementation(projects.features.tangempay.onboarding.api) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt new file mode 100644 index 0000000000..c770c768d1 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.components + +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.tangempay.model.TangemPayOnboardingModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import com.tangem.features.tangempay.ui.TandemPayOnboardingScreen + +internal class DefaultTangemPayOnboardingComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: TangemPayOnboardingComponent.Params, +) : TangemPayOnboardingComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayOnboardingModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.screenState.collectAsStateWithLifecycle() + TandemPayOnboardingScreen(modifier = modifier, state = state) + } + + @AssistedFactory + interface Factory : TangemPayOnboardingComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayOnboardingComponent.Params, + ): DefaultTangemPayOnboardingComponent + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt new file mode 100644 index 0000000000..a843aade0a --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt @@ -0,0 +1,23 @@ +package com.tangem.features.tangempay.deeplink + +import android.net.Uri +import dagger.assisted.Assisted +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultOnboardVisaDeepLinkHandler @AssistedInject constructor( + @Assisted uri: Uri, + appRouter: AppRouter, +) : OnboardVisaDeepLinkHandler { + + init { + appRouter.push(AppRoute.TangemPayOnboarding(uri.toString())) + } + + @AssistedFactory + interface Factory : OnboardVisaDeepLinkHandler.Factory { + override fun create(uri: Uri): DefaultOnboardVisaDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt new file mode 100644 index 0000000000..a1d13bcabc --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay.di + +import com.tangem.features.tangempay.deeplink.DefaultOnboardVisaDeepLinkHandler +import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +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 TangemPayDeeplinkModule { + + @Binds + @Singleton + fun bindDeepLinkHandlerFactory(impl: DefaultOnboardVisaDeepLinkHandler.Factory): OnboardVisaDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingFeatureModule.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingFeatureModule.kt new file mode 100644 index 0000000000..6e36c8215a --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingFeatureModule.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tangempay.di + +import com.tangem.features.tangempay.components.DefaultTangemPayOnboardingComponent +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayOnboardingFeatureModule { + + @Binds + fun bindFactory(impl: DefaultTangemPayOnboardingComponent.Factory): TangemPayOnboardingComponent.Factory +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingModelsModule.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingModelsModule.kt new file mode 100644 index 0000000000..b4b1892e56 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayOnboardingModelsModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.tangempay.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.tangempay.model.TangemPayOnboardingModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface TangemPayOnboardingModelsModule { + + @Binds + @IntoMap + @ClassKey(TangemPayOnboardingModel::class) + fun bindModel(model: TangemPayOnboardingModel): Model +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt new file mode 100644 index 0000000000..02ca64323d --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -0,0 +1,28 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent +import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayOnboardingModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + @Suppress("UnusedPrivateMember") + private val params = paramsContainer.require() + + private val _screenState: MutableStateFlow = MutableStateFlow(getInitState()) + val screenState = _screenState.asStateFlow() + + private fun getInitState() = TangemPayOnboardingScreenState +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt new file mode 100644 index 0000000000..008f2c8798 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt @@ -0,0 +1,31 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.AppBarWithBackButton + +@Suppress("UnusedPrivateMember") +@Composable +internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { + AppBarWithBackButton( + modifier = Modifier.statusBarsPadding(), + onBackClick = {}, + text = "", + iconRes = R.drawable.ic_back_24, + ) + }, + content = { paddingValues -> + val contentModifier = Modifier + .padding(paddingValues) + .fillMaxSize() + }, + ) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt new file mode 100644 index 0000000000..a0681a0db9 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt @@ -0,0 +1,3 @@ +package com.tangem.features.tangempay.ui + +internal object TangemPayOnboardingScreenState \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 19c6d1c875..99aee86df3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -272,6 +272,9 @@ include(":features:tangempay:main:impl") include(":features:tangempay:details:api") include(":features:tangempay:details:impl") +include(":features:tangempay:onboarding:api") +include(":features:tangempay:onboarding:impl") + include(":features:create-wallet-selection:api") include(":features:create-wallet-selection:impl") @@ -380,4 +383,5 @@ include(":data:blockaid") include(":data:swap") include(":data:express") include(":data:wallet-manager") -// endregion Data modules \ No newline at end of file +// endregion Data modules +include(":features:tangempay:onboarding") \ No newline at end of file From 194e750c933c508bbc4aca4f78b426ad61400334 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Sep 2025 18:54:08 +0400 Subject: [PATCH 057/132] Updated on 2026-08-14 --- app/build.gradle.kts | 1 - .../com/tangem/tap/ApplicationEntryPoint.kt | 6 +- .../main/java/com/tangem/tap/MainActivity.kt | 21 +- .../java/com/tangem/tap/TangemApplication.kt | 16 +- .../com/tangem/tap/common/DialogManager.kt | 89 +-- .../common/analytics/events/WalletConnect.kt | 120 --- .../com/tangem/tap/common/redux/AppReducer.kt | 2 - .../com/tangem/tap/common/redux/AppState.kt | 4 - .../com/tangem/tap/di/IntentHandlingModule.kt | 5 - .../tap/domain/walletconnect/BnbHelper.kt | 77 -- .../tap/domain/walletconnect/EthSignHelper.kt | 31 - .../walletconnect/WalletConnectSdkHelper.kt | 529 -------------- .../app/TangemWcBlockchainHelper.kt | 119 --- .../app/WalletConnectEventsHandlerImpl.kt | 53 -- .../DefaultLegacyWalletConnectRepository.kt | 686 ------------------ .../DefaultWalletConnectSessionsRepository.kt | 57 -- .../di/WalletConnectInteractorModule.kt | 110 --- .../domain/LegacyWalletConnectRepository.kt | 36 - .../domain/WalletConnectEventsHandler.kt | 21 - .../domain/WalletConnectInteractor.kt | 457 ------------ .../domain/WcBlockchainHelper.kt | 20 - .../walletconnect2/domain/WcJrpcMethods.kt | 297 -------- .../domain/WcPreparedRequest.kt | 68 -- .../domain/WcSessionRequestConverter.kt | 184 ----- .../walletconnect2/domain/models/BnbData.kt | 10 - .../domain/models/EthTransactionData.kt | 15 - .../domain/models/NetworkNamespace.kt | 4 - .../domain/models/RequestData.kt | 8 - .../domain/models/WalletConnectError.kt | 31 - .../domain/models/WalletConnectEvents.kt | 32 - .../domain/models/WalletConnectSession.kt | 8 - .../models/binance/WcBinanceCancelOrder.kt | 35 - .../models/binance/WcBinanceTradeOrder.kt | 73 -- .../models/binance/WcBinanceTransferOrder.kt | 51 -- .../models/binance/WcBinanceTxConfirmParam.kt | 13 - .../domain/models/solana/SolanaSignMessage.kt | 13 - .../models/solana/SolanaTransactionRequest.kt | 14 - .../DefaultWalletConnectFeatureToggles.kt | 12 - .../tangem/tap/features/demo/DemoHelper.kt | 24 +- .../details/redux/DetailsMiddleware.kt | 2 +- .../walletconnect/WalletConnectAction.kt | 45 -- .../walletconnect/WalletConnectMiddleware.kt | 202 ------ .../walletconnect/WalletConnectReducer.kt | 26 - .../redux/walletconnect/WalletConnectState.kt | 173 ----- .../DefaultWalletConnectComponent.kt | 73 -- .../ui/walletconnect/WalletConnectModel.kt | 72 -- .../ui/walletconnect/WalletConnectScreen.kt | 169 ----- .../walletconnect/WalletConnectScreenState.kt | 15 - .../api/WalletConnectComponent.kt | 10 - .../di/WalletConnectFeatureModule.kt | 25 - .../dialogs/BnbTransactionDialog.kt | 55 -- .../dialogs/PersonalSignDialog.kt | 37 - .../dialogs/SessionProposalDialog.kt | 42 -- .../dialogs/SignTransactionDialog.kt | 30 - .../dialogs/SignTransactionsDialog.kt | 30 - .../dialogs/TransactionDialog.kt | 61 -- .../WalletConnectLinkIntentHandler.kt | 48 -- .../products/wallet/redux/BackupMiddleware.kt | 11 +- .../middlewares/TradeCryptoMiddleware.kt | 2 +- .../welcome/redux/WelcomeMiddleware.kt | 37 +- .../tap/proxy/redux/DaggerGraphAction.kt | 2 - .../tap/proxy/redux/DaggerGraphReducer.kt | 1 - .../tap/proxy/redux/DaggerGraphState.kt | 4 - .../tangem/tap/routing/utils/ChildFactory.kt | 34 +- .../configs/feature_toggles_config.json | 4 - .../di/WalletConnectDataModule.kt | 3 - .../sessions/DefaultWcSessionsManager.kt | 43 -- .../walletconnect/model/legacy/Account.kt | 16 - .../walletconnect/model/legacy/Session.kt | 13 - .../legacy/WalletConnectSessionsRepository.kt | 9 - .../components/WalletConnectFeatureToggles.kt | 5 - .../connections/routing/WcRoutingModel.kt | 8 +- .../DefaultWalletConnectDeepLinkHandler.kt | 18 +- gradle/dependencies.toml | 2 - 74 files changed, 52 insertions(+), 4627 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect/BnbHelper.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect/EthSignHelper.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/app/WalletConnectEventsHandlerImpl.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectEventsHandler.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcJrpcMethods.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcPreparedRequest.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/BnbData.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/EthTransactionData.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/NetworkNamespace.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/RequestData.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectError.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectSession.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceCancelOrder.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTradeOrder.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTransferOrder.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTxConfirmParam.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaSignMessage.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaTransactionRequest.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/DefaultWalletConnectFeatureToggles.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectReducer.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/DefaultWalletConnectComponent.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreen.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreenState.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/api/WalletConnectComponent.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/di/WalletConnectFeatureModule.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/BnbTransactionDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/PersonalSignDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SessionProposalDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionsDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/TransactionDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/intentHandler/handlers/WalletConnectLinkIntentHandler.kt delete mode 100644 domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt delete mode 100644 domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt delete mode 100644 domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt delete mode 100644 features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectFeatureToggles.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3458dfc6a4..1188d0e283 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -347,7 +347,6 @@ dependencies { implementation(deps.coil.gif) implementation(deps.coil.svg) implementation(deps.amplitude) - implementation(deps.kotsonGson) implementation(deps.spongecastle.core) implementation(deps.lottie) implementation(deps.compose.accompanist.appCompatTheme) diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index ce53e6dc05..2f4c389818 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -35,6 +35,7 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager @@ -50,7 +51,6 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository @EntryPoint @InstallIn(SingletonComponent::class) @@ -71,8 +71,6 @@ interface ApplicationEntryPoint { fun getCardScanningFeatureToggles(): CardScanningFeatureToggles - fun getWalletConnect2Repository(): WalletConnect2Repository - fun getScanCardProcessor(): ScanCardProcessor fun getAppCurrencyRepository(): AppCurrencyRepository @@ -151,4 +149,6 @@ interface ApplicationEntryPoint { fun getTangemHotSdk(): TangemHotSdk fun getHotWalletFeatureToggles(): HotWalletFeatureToggles + + fun getWcInitializeUseCase(): WcInitializeUseCase } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 659aaec327..7217bbef8d 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -40,6 +40,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.settings.SetGooglePayAvailabilityUseCase import com.tangem.domain.settings.SetGoogleServicesAvailabilityUseCase @@ -49,12 +50,10 @@ import com.tangem.domain.staking.SendUnsubmittedHashesUseCase import com.tangem.domain.tokens.GetPolkadotCheckHasImmortalUseCase import com.tangem.domain.tokens.GetPolkadotCheckHasResetUseCase import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tester.api.TesterMenuLauncher -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles import com.tangem.google.GoogleServicesHelper import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder @@ -64,11 +63,9 @@ import com.tangem.tap.common.DialogManager import com.tangem.tap.common.OnActivityResultCallback import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor import com.tangem.tap.features.intentHandler.IntentProcessor import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler -import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler import com.tangem.tap.features.main.MainViewModel import com.tangem.tap.proxy.redux.DaggerGraphAction import com.tangem.tap.routing.component.RoutingComponent @@ -116,9 +113,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject lateinit var scanCardUseCase: ScanCardUseCase - @Inject - lateinit var walletConnectInteractor: WalletConnectInteractor - @Inject lateinit var settingsRepository: SettingsRepository @@ -171,9 +165,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var deeplinkFactory: DeepLinkFactory - @Inject - internal lateinit var walletConnectFeatureToggles: WalletConnectFeatureToggles - @Inject internal lateinit var urlOpener: UrlOpener @@ -183,9 +174,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var intentProcessor: IntentProcessor - @Inject - internal lateinit var walletConnectLinkIntentHandler: WalletConnectLinkIntentHandler - @Inject internal lateinit var onPushClickedIntentHandler: OnPushClickedIntentHandler @@ -201,7 +189,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var tangemPayFeatureToggles: TangemPayFeatureToggles - internal val viewModel: MainViewModel by viewModels() + private val viewModel: MainViewModel by viewModels() private lateinit var appThemeModeFlow: SharedFlow @@ -293,7 +281,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { store.dispatch( DaggerGraphAction.SetActivityDependencies( scanCardUseCase = scanCardUseCase, - walletConnectInteractor = walletConnectInteractor, cardSdkConfigRepository = cardSdkConfigRepository, ), ) @@ -369,10 +356,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { private fun initIntentHandlers() { intentProcessor.addHandler(onPushClickedIntentHandler) - - if (!walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { - intentProcessor.addHandler(walletConnectLinkIntentHandler) - } } private fun updateAppTheme(appThemeMode: AppThemeMode) { diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 4c35b0ee8f..1ed30f5829 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -75,10 +75,12 @@ import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.rekotlin.Store import timber.log.Timber -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository lateinit var store: Store @@ -112,9 +114,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val cardScanningFeatureToggles: CardScanningFeatureToggles get() = entryPoint.getCardScanningFeatureToggles() - private val walletConnect2Repository: WalletConnect2Repository - get() = entryPoint.getWalletConnect2Repository() - private val scanCardProcessor: ScanCardProcessor get() = entryPoint.getScanCardProcessor() @@ -237,6 +236,9 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val hotWalletFeatureToggles get() = entryPoint.getHotWalletFeatureToggles() + private val wcInitializeUseCase + get() = entryPoint.getWcInitializeUseCase() + // endregion private val appScope = MainScope() @@ -331,7 +333,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat ) appStateHolder.mainStore = store - walletConnect2Repository.init( + + wcInitializeUseCase.init( projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId, ) } @@ -344,7 +347,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat daggerGraphState = DaggerGraphState( networkConnectionManager = networkConnectionManager, cardScanningFeatureToggles = cardScanningFeatureToggles, - walletConnectRepository = walletConnect2Repository, scanCardProcessor = scanCardProcessor, appCurrencyRepository = appCurrencyRepository, walletManagersFacade = walletManagersFacade, diff --git a/app/src/main/java/com/tangem/tap/common/DialogManager.kt b/app/src/main/java/com/tangem/tap/common/DialogManager.kt index 814384c569..c863320c2a 100644 --- a/app/src/main/java/com/tangem/tap/common/DialogManager.kt +++ b/app/src/main/java/com/tangem/tap/common/DialogManager.kt @@ -5,9 +5,10 @@ import android.content.Context import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.redux.AppDialog import com.tangem.tap.common.redux.global.GlobalState -import com.tangem.tap.common.ui.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectDialog -import com.tangem.tap.features.details.ui.walletconnect.dialogs.* +import com.tangem.tap.common.ui.ScanFailsDialog +import com.tangem.tap.common.ui.SimpleAlertDialog +import com.tangem.tap.common.ui.SimpleCancelableAlertDialog +import com.tangem.tap.common.ui.SimpleOkDialog import com.tangem.tap.features.onboarding.OnboardingDialog import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.ConfirmDiscardingBackupDialog @@ -59,88 +60,6 @@ class DialogManager : StoreSubscriber { context = context, ) is OnboardingDialog.WalletActivationError -> WalletActivationErrorDialog.create(context, state.dialog) - is WalletConnectDialog.UnsupportedCard -> - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_scanner_error_not_valid_card, - context = context, - ) - is WalletConnectDialog.AddNetwork -> { - val message = context.getString( - R.string.wallet_connect_error_missing_blockchains, - ) + state.dialog.networks.joinToString() - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - message = message, - context = context, - ) - } - is WalletConnectDialog.OpeningSessionRejected -> { - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_same_wcuri, - context = context, - ) - } - is WalletConnectDialog.SessionTimeout -> { - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_error_timeout, - context = context, - ) - } - is WalletConnectDialog.RequestTransaction -> TransactionDialog.create(state.dialog.data, context) - is WalletConnectDialog.PersonalSign -> PersonalSignDialog.create(state.dialog.data, context) - is WalletConnectDialog.BnbTransactionDialog -> - BnbTransactionDialog.create( - preparedData = state.dialog.data, - context = context, - ) - is WalletConnectDialog.UnsupportedNetwork -> { - val warning = if (state.dialog.networks.isNullOrEmpty()) { - context.getString(R.string.wallet_connect_scanner_error_unsupported_network) - } else { - context.getString(R.string.wallet_connect_error_unsupported_blockchains) + - state.dialog.networks.joinToString() - } - SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - message = warning, - context = context, - ) - } - is WalletConnectDialog.UnsupportedDapp -> SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - messageRes = R.string.wallet_connect_error_unsupported_dapp, - context = context, - ) - is WalletConnectDialog.SessionProposalDialog -> { - SessionProposalDialog.create( - sessionProposal = state.dialog.sessionProposal, - networks = state.dialog.networks, - context = context, - onApprove = state.dialog.onApprove, - onReject = state.dialog.onReject, - ) - } - is WalletConnectDialog.SignTransactionDialog -> SignTransactionDialog.create( - preparedData = state.dialog.data, - context = context, - ) - is WalletConnectDialog.SignTransactionsDialog -> SignTransactionsDialog.create( - preparedData = state.dialog.data, - context = context, - ) - is WalletConnectDialog.PairConnectErrorDialog -> SimpleAlertDialog.create( - titleRes = R.string.wallet_connect_title, - message = state.dialog.error.message, - context = context, - ) - is WalletConnectDialog.UnsupportedWcVersion -> SimpleAlertDialog.create( - titleRes = R.string.common_error, - messageRes = R.string.unsupported_wc_version, - context = context, - ) is BackupDialog.UnfinishedBackupFound -> UnfinishedBackupFoundDialog.create( context = context, scanResponse = state.dialog.scanResponse, diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt deleted file mode 100644 index 24566a94f8..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.tangem.tap.common.analytics.events - -import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType - -/** -[REDACTED_AUTHOR] - */ -internal sealed class WalletConnect( - event: String, - params: Map = mapOf(), -) : AnalyticsEvent("Wallet Connect", event, params) { - - class ScreenOpened : WalletConnect(event = "WC Screen Opened") - class NewSessionInitiated(source: SourceType) : WalletConnect( - event = "Session Initiated", - params = mapOf( - AnalyticsParam.SOURCE to when (source) { - SourceType.QR -> "QR" - SourceType.DEEPLINK -> "DeepLink" - SourceType.CLIPBOARD -> "Clipboard" - SourceType.ETC -> "etc" - }, - ), - ) - - data object SessionFailed : WalletConnect( - event = "Session Failed", - ) - - class DAppConnectionRequested( - blockchainNames: List, - ) : WalletConnect( - event = "dApp Connection Requested", - params = mapOf( - AnalyticsParam.NETWORKS to blockchainNames.joinToString(","), - ), - ) - - class DAppConnected(dAppName: String, dAppUrl: String, blockchainNames: List) : WalletConnect( - event = "dApp Connected", - params = mapOf( - AnalyticsParam.DAPP_NAME to dAppName, - AnalyticsParam.DAPP_URL to dAppUrl, - AnalyticsParam.BLOCKCHAIN to blockchainNames.joinToString(","), - ), - ) - - class DAppConnectionFailed(dAppName: String, dAppUrl: String, blockchainNames: List) : WalletConnect( - event = "dApp Connection Failed", - params = mapOf( - AnalyticsParam.DAPP_NAME to dAppName, - AnalyticsParam.DAPP_URL to dAppUrl, - AnalyticsParam.BLOCKCHAIN to blockchainNames.joinToString(","), - ), - ) - - class SessionDisconnected(dAppName: String, dAppUrl: String) : WalletConnect( - event = "dApp Disconnected", - params = mapOf( - AnalyticsParam.DAPP_NAME to dAppName, - AnalyticsParam.DAPP_URL to dAppUrl, - ), - ) - - class SignatureRequestHandled( - params: RequestHandledParams, - ) : WalletConnect( - event = "Signature Request Handled", - params = params.toParamsMap(), - ) - - class SignatureRequestReceived( - params: RequestHandledParams, - ) : WalletConnect( - event = "Signature Request Received", - params = params.toParamsMap(), - ) - - class SignatureRequestFailed( - params: RequestHandledParams, - ) : WalletConnect( - event = "Signature Request Failed", - params = params.toParamsMap(), - ) - - data class RequestHandledParams( - val dAppName: String, - val dAppUrl: String, - val methodName: String, - val blockchain: String, - val errorCode: String? = null, - val errorDescription: String? = null, - ) { - fun toParamsMap(): Map { - val validation = if (errorCode == null) Validation.SUCCESS.param else Validation.FAIL.param - val code = errorCode ?: SUCCESS_CODE - return buildMap { - put(AnalyticsParam.DAPP_NAME, dAppName) - put(AnalyticsParam.DAPP_URL, dAppUrl) - put(AnalyticsParam.METHOD_NAME, methodName) - put(AnalyticsParam.BLOCKCHAIN, blockchain) - put(AnalyticsParam.VALIDATION, validation) - put(AnalyticsParam.ERROR_CODE, code) - if (errorDescription != null) { - put(AnalyticsParam.ERROR_DESCRIPTION, errorDescription) - } - } - } - } - - enum class Validation(val param: String) { - SUCCESS("Success"), - FAIL("Fail"), - } - - private companion object { - const val SUCCESS_CODE = "0" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index 8bd03da756..6099bad60a 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -2,7 +2,6 @@ package com.tangem.tap.common.redux import com.tangem.tap.common.redux.global.globalReducer import com.tangem.tap.features.details.redux.DetailsReducer -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectReducer import com.tangem.tap.features.welcome.redux.WelcomeReducer import com.tangem.tap.proxy.redux.DaggerGraphReducer import org.rekotlin.Action @@ -14,7 +13,6 @@ fun appReducer(action: Action, state: AppState?): AppState { return AppState( globalState = globalReducer(action, state), detailsState = DetailsReducer.reduce(action, state), - walletConnectState = WalletConnectReducer.reduce(action, state.walletConnectState), welcomeState = WelcomeReducer.reduce(action, state), daggerGraphState = DaggerGraphReducer.reduce(action, state), ) diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index 427a103a98..7c3065b0f4 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -5,8 +5,6 @@ import com.tangem.tap.common.redux.global.GlobalState import com.tangem.tap.common.redux.legacy.LegacyMiddleware import com.tangem.tap.features.details.redux.DetailsMiddleware import com.tangem.tap.features.details.redux.DetailsState -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectMiddleware -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState import com.tangem.tap.features.onboarding.products.wallet.redux.BackupMiddleware import com.tangem.tap.features.wallet.redux.middlewares.TradeCryptoMiddleware import com.tangem.tap.features.welcome.redux.WelcomeMiddleware @@ -19,7 +17,6 @@ import org.rekotlin.StateType data class AppState( val globalState: GlobalState = GlobalState(), val detailsState: DetailsState = DetailsState(), - val walletConnectState: WalletConnectState = WalletConnectState(), val welcomeState: WelcomeState = WelcomeState(), val daggerGraphState: DaggerGraphState = DaggerGraphState(), ) : StateType { @@ -30,7 +27,6 @@ data class AppState( logMiddleware, GlobalMiddleware.handler, DetailsMiddleware().detailsMiddleware, - WalletConnectMiddleware().walletConnectMiddleware, BackupMiddleware().backupMiddleware, WelcomeMiddleware().middleware, LockUserWalletsTimerMiddleware().middleware, diff --git a/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt b/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt index cd7becb2e0..b84f990fa6 100644 --- a/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt +++ b/app/src/main/java/com/tangem/tap/di/IntentHandlingModule.kt @@ -4,7 +4,6 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.tap.features.intentHandler.IntentProcessor import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler -import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -19,10 +18,6 @@ internal object IntentHandlingModule { @Singleton fun provideBackgroundScanIntentHandler(): BackgroundScanIntentHandler = BackgroundScanIntentHandler() - @Provides - @Singleton - fun provideWalletConnectLinkIntentHandler(): WalletConnectLinkIntentHandler = WalletConnectLinkIntentHandler() - @Provides @Singleton fun provideOnPushClickedIntentHandler(analyticsEventHandler: AnalyticsEventHandler): OnPushClickedIntentHandler = diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/BnbHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/BnbHelper.kt deleted file mode 100644 index 0e6ccce36b..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/BnbHelper.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.tangem.tap.domain.walletconnect - -import com.github.salomonbrys.kotson.registerTypeAdapter -import com.google.gson.GsonBuilder -import com.tangem.blockchain.common.Blockchain -import com.tangem.common.extensions.calculateSha256 -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTradeOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTransferOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.tradeOrderSerializer -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData -import com.tangem.tap.features.details.redux.walletconnect.TradeData -import com.tangem.utils.extensions.stripZeroPlainString -import timber.log.Timber - -internal object BnbHelper { - - fun createMessageData(order: WcBinanceTransferOrder): BinanceMessageData.Transfer { - val input = order.msgs.first().inputs.first() - val output = order.msgs.first().inputs.first() - - val currency = - input.coins.firstOrNull()?.denom ?: Blockchain.Binance - val amount = input.coins - .mapNotNull { if (it.denom == currency) it.amount else null } - .sum() - .toBigDecimal() - .movePointLeft(Blockchain.Binance.decimals()) - .stripZeroPlainString() - - val gson = GsonBuilder() - .registerTypeAdapter(tradeOrderSerializer) - .serializeNulls() - .create() - - return BinanceMessageData.Transfer( - outputAddress = output.address, - amount = amount, - address = input.address, - data = gson.toJson(order).toByteArray().calculateSha256(), - ) - } - - fun createMessageData(order: WcBinanceTradeOrder): BinanceMessageData.Trade { - val address = order.msgs.first().sender - - val tradeData = order.msgs.map { - val price = it.price.toBigDecimal() - .movePointLeft(Blockchain.Binance.decimals()) - .stripTrailingZeros() - val quantity = it.quantity.toBigDecimal() - .movePointLeft(Blockchain.Binance.decimals()) - .stripTrailingZeros() - - val amount = price * quantity - val symbol = it.symbol.substringBefore("-") - - TradeData( - price = "$price ${Blockchain.Binance.currency}", - quantity = "$quantity $symbol", - amount = "$amount ${Blockchain.Binance.currency}", - symbol = symbol, - ) - } - val gson = GsonBuilder() - .registerTypeAdapter(tradeOrderSerializer) - .serializeNulls() - .create() - val serialized = gson.toJson(order) - Timber.d(serialized) - - return BinanceMessageData.Trade( - tradeData = tradeData, - address = address, - data = serialized.toByteArray().calculateSha256(), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/EthSignHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/EthSignHelper.kt deleted file mode 100644 index 80d7b046e9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/EthSignHelper.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.tap.domain.walletconnect - -import com.github.salomonbrys.kotson.fromJson -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonParser - -object EthSignHelper { - private val gson: Gson by lazy { - GsonBuilder() - .setPrettyPrinting() - .serializeNulls() - .create() - } - - fun tryToParseEthTypedMessageString(message: String): String? { - return try { - val messageString = message - .replace("\\", "") - .removePrefix("\"") - .removeSuffix("\"") - val messageJson = JsonParser().parse(messageString) - val filteredMap = gson.fromJson>(messageJson) - .filterKeys { it == "domain" || it == "message" } - - gson.toJson(filteredMap) - } catch (exception: Exception) { - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt deleted file mode 100644 index e6e92ccda4..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt +++ /dev/null @@ -1,529 +0,0 @@ -package com.tangem.tap.domain.walletconnect - -import com.tangem.Message -import com.tangem.blockchain.blockchains.ethereum.EthereumGasLoader -import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras -import com.tangem.blockchain.blockchains.ethereum.EthereumUtils -import com.tangem.blockchain.common.* -import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.extensions.* -import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.common.CompletionResult -import com.tangem.common.extensions.hexToBytes -import com.tangem.common.extensions.toDecompressedPublicKey -import com.tangem.common.extensions.toHexString -import com.tangem.data.walletconnect.network.ethereum.LegacySdkHelper -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.operations.sign.SignHashCommand -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.extensions.safeUpdate -import com.tangem.tap.common.extensions.toFormattedString -import com.tangem.tap.domain.walletconnect2.domain.TransactionType -import com.tangem.tap.domain.walletconnect2.domain.WcEthereumTransaction -import com.tangem.tap.domain.walletconnect2.domain.WcSignMessage -import com.tangem.tap.domain.walletconnect2.domain.models.EthTransactionData -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTradeOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTransferOrder -import com.tangem.tap.features.demo.isDemoCard -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType -import com.tangem.tap.features.details.redux.walletconnect.WcPersonalSignData -import com.tangem.tap.features.details.redux.walletconnect.WcTransactionData -import com.tangem.tap.features.details.ui.walletconnect.dialogs.PersonalSignDialogData -import com.tangem.tap.features.details.ui.walletconnect.dialogs.TransactionRequestDialogData -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import org.json.JSONArray -import org.json.JSONObject -import timber.log.Timber -import java.math.BigDecimal - -@Suppress("LargeClass") -class WalletConnectSdkHelper { - - private val userWalletsListManager by lazy { - store.inject(DaggerGraphState::generalUserWalletsListManager) - } - - private val userWalletsListRepository by lazy { - store.inject(DaggerGraphState::userWalletsListRepository) - } - - private val hotWalletFeatureToggles by lazy { - store.inject(DaggerGraphState::hotWalletFeatureToggles) - } - - @Suppress("MagicNumber") - suspend fun prepareTransactionData(data: EthTransactionData): WcTransactionData { - val transaction = data.transaction - val blockchain = requireNotNull(Blockchain.fromNetworkId(data.networkId)) { "Blockchain not found" } - val walletManager = requireNotNull(getWalletManager(blockchain, data.rawDerivationPath)) { - "WalletManager not found" - } - - walletManager.safeUpdate(isDemoCard()) - val wallet = walletManager.wallet - val balance = requireNotNull(wallet.amounts[AmountType.Coin]?.value) { - "Coin balance not found" - } - - val decimals = wallet.blockchain.decimals() - - val value = (transaction.value ?: "0") - .hexToBigDecimal() - .movePointLeft(decimals) - - requireNotNull(value) { - "Transaction amount is null" - } - - // TODO move fee calculation to SDK getFee() [REDACTED_JIRA] - val gasLimit = getGasLimitFromTx(value, walletManager, transaction, blockchain) - val gasPrice = getGasPrice(walletManager, transaction) - - val feeDecimal = (gasLimit * gasPrice).movePointLeft(decimals) - val total = value + feeDecimal - val feeAmount = Amount(feeDecimal, wallet.blockchain) - - val destinationAddress = requireNotNull(transaction.to) { "Destination address is null" } - - val fee = if (blockchain.isEvm()) { - // TODO [REDACTED_JIRA] - // workaround for Mantle, remove after [REDACTED_JIRA] - val patchedAmount = if (blockchain == Blockchain.Mantle) { - feeAmount.copy(value = feeAmount.value?.multiply(MANTLE_FEE_ESTIMATE_MULTIPLIER)) - } else { - feeAmount - } - Fee.Ethereum.Legacy(patchedAmount, gasLimit.toBigInteger(), gasPrice.toBigInteger()) - } else { - Fee.Common(feeAmount) - } - val transactionData = TransactionData.Uncompiled( - amount = Amount(value, wallet.blockchain), - fee = fee, - sourceAddress = transaction.from, - destinationAddress = destinationAddress, - extras = EthereumTransactionExtras( - callData = CompiledSmartContractCallData(transaction.data.removePrefix(HEX_PREFIX).hexToBytes()), - gasLimit = gasLimit.toBigInteger(), - nonce = transaction.nonce?.hexToBigDecimal()?.toBigInteger(), - ), - ) - - val dialogData = TransactionRequestDialogData( - dAppName = data.metaName, - dAppUrl = data.metaUrl, - amount = value.toFormattedString(decimals), - feeAmount = feeDecimal.toFormattedString(decimals), - totalAmount = total.toFormattedString(decimals), - balance = balance.toFormattedString(decimals), - isEnoughFundsToSend = balance - total >= BigDecimal.ZERO, - topic = data.topic, - id = data.id, - type = data.type, - ) - - return WcTransactionData( - type = data.type, - transaction = transactionData, - topic = data.topic, - id = data.id, - walletManager = walletManager, - dialogData = dialogData, - ) - } - - suspend fun isDemoCard(): Boolean { - val userWallet = getSelectedWallet() ?: return false - return userWallet is UserWallet.Cold && userWallet.scanResponse.isDemoCard() - } - - private suspend fun getWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager? { - val userWallet = getSelectedWallet() ?: return null - val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) - return walletManagerFacade.getOrCreateWalletManager( - userWalletId = userWallet.walletId, - blockchain = blockchain, - derivationPath = derivationPath, - ) - } - - suspend fun completeTransaction(data: WcTransactionData, cardId: String?): String? { - return when (data.type) { - WcEthTransactionType.EthSendTransaction -> sendTransaction(data, cardId) - WcEthTransactionType.EthSignTransaction -> signTransaction(data, cardId) - } - } - - private suspend fun getGasPrice(walletManager: WalletManager, transaction: WcEthereumTransaction): BigDecimal { - val txGasPrice = transaction.gasPrice?.hexToBigDecimal() - if (txGasPrice != null) { - return txGasPrice - } - return when (val result = (walletManager as? EthereumGasLoader)?.getGasPrice()) { - is Result.Success -> result.data.toBigDecimal() - is Result.Failure -> { - (result.error as? Throwable)?.let { Timber.e(it, "getGasPrice failed") } - - error("Unable to get gas price: ${result.error}") - } - null -> error("Gas price is null") - } - } - - private suspend fun getGasLimitFromTx( - value: BigDecimal, - walletManager: WalletManager, - transaction: WcEthereumTransaction, - blockchain: Blockchain, - ): BigDecimal { - return transaction.gas?.hexToBigDecimal() - ?: transaction.gasLimit?.hexToBigDecimal() - ?: getGasLimitFromBlockchain( - value = value, - walletManager = walletManager, - transaction = transaction, - ).increaseForMantleIfNeeded(blockchain) - } - - private suspend fun getGasLimitFromBlockchain( - value: BigDecimal, - walletManager: WalletManager, - transaction: WcEthereumTransaction, - ): BigDecimal { - val gasLimitResult = (walletManager as? EthereumGasLoader)?.getGasLimit( - amount = Amount(value, walletManager.wallet.blockchain), - destination = transaction.to ?: "", - callData = CompiledSmartContractCallData(transaction.data.hexToBytes()), - ) - return when (gasLimitResult) { - is Result.Success -> gasLimitResult.data.toBigDecimal().multiply(BigDecimal("1.2")) - is Result.Failure -> { - (gasLimitResult.error as? Throwable)?.let { Timber.e(it, "getGasLimit failed") } - DEFAULT_MAX_GASLIMIT.toBigDecimal() // Set high gasLimit if not provided - } - else -> DEFAULT_MAX_GASLIMIT.toBigDecimal() // Set high gasLimit if not provided - } - } - - // TODO Workaround for Mantle. Remove after [REDACTED_JIRA] - private fun BigDecimal.increaseForMantleIfNeeded(blockchain: Blockchain): BigDecimal { - return if (blockchain == Blockchain.Mantle) { - this.multiply(MANTLE_FEE_ESTIMATE_MULTIPLIER) - } else { - this - } - } - - private suspend fun sendTransaction(data: WcTransactionData, cardId: String?): String? { - val sdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk - - val result = (data.walletManager as TransactionSender).send( - transactionData = data.transaction, - signer = store.inject(DaggerGraphState::transactionSignerFactory).createTransactionSigner( - cardId = cardId, - sdk = sdk, - twinKey = null, // use null here because twin doesn't support WC - ), - ) - return when (result) { - is Result.Success -> { - val hash = result.data.hash - if (hash.startsWith(HEX_PREFIX)) { - hash - } else { - HEX_PREFIX + hash - } - } - is Result.Failure -> { - Timber.e(result.error as BlockchainSdkError) - null - } - } - } - - private suspend fun signTransaction(data: WcTransactionData, cardId: String?): String? { - val dataToSign = EthereumUtils.buildTransactionToSign( - transactionData = data.transaction, - blockchain = data.walletManager.wallet.blockchain, - ) - - val command = SignHashCommand( - hash = dataToSign.hash, - walletPublicKey = data.walletManager.wallet.publicKey.seedKey, - derivationPath = data.walletManager.wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - initialMessage = Message(), - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val hash = EthereumUtils.prepareTransactionToSend( - signature = result.data.signature, - transactionToSign = dataToSign, - walletPublicKey = data.walletManager.wallet.publicKey, - blockchain = data.walletManager.wallet.blockchain, - ).toHexString() - if (hash.startsWith(HEX_PREFIX)) { - hash - } else { - HEX_PREFIX + hash - } - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - fun prepareBnbTradeOrder(data: WcBinanceTradeOrder): BinanceMessageData.Trade { - return BnbHelper.createMessageData(data) - } - - fun prepareBnbTransferOrder(data: WcBinanceTransferOrder): BinanceMessageData.Transfer { - return BnbHelper.createMessageData(data) - } - - suspend fun signBnbTransaction( - data: ByteArray, - networkId: String, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - - val command = SignHashCommand( - hash = data, - walletPublicKey = wallet.publicKey.seedKey, - derivationPath = wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - initialMessage = Message(), - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val key = wallet.publicKey.blockchainKey.toDecompressedPublicKey() - getBnbResultString( - key.toHexString(), - result.data.signature.toHexString(), - ) - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - fun prepareDataForPersonalSign( - message: WcSignMessage, - topic: String, - metaName: String, - id: Long, - ): WcPersonalSignData { - val messageData = when (message.type) { - WcSignMessage.WCSignType.MESSAGE, - WcSignMessage.WCSignType.PERSONAL_MESSAGE, - -> createMessageData(message) - WcSignMessage.WCSignType.TYPED_MESSAGE -> EthereumUtils.makeTypedDataHash(message.data) - WcSignMessage.WCSignType.SOLANA_MESSAGE -> message.data.decodeBase58() - } - - val messageString = message.data.hexToAscii() - ?: EthSignHelper.tryToParseEthTypedMessageString(message.data) - ?: message.data - - val dialogData = PersonalSignDialogData( - dAppName = metaName, - message = messageString, - topic = topic, - id = id, - ) - return WcPersonalSignData( - hash = requireNotNull(messageData) { "Message data must not be null" }, - topic = topic, - id = id, - dialogData = dialogData, - type = message.type, - ) - } - - private fun createMessageData(message: WcSignMessage): ByteArray = LegacySdkHelper.createMessageData(message.data) - - private fun String.hexToAscii(): String? = LegacySdkHelper.hexToAscii(hex = this) - - suspend fun signPersonalMessage( - hashToSign: ByteArray, - networkId: String, - type: WcSignMessage.WCSignType, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - - val command = SignHashCommand( - hash = hashToSign, - walletPublicKey = wallet.publicKey.seedKey, - derivationPath = wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val signedHash = result.data.signature - - return when (type) { - WcSignMessage.WCSignType.SOLANA_MESSAGE -> getSolanaResultString(signedHash) - else -> UnmarshalHelper.unmarshalSignatureExtended( - signature = signedHash, - hash = hashToSign, - publicKey = wallet.publicKey.blockchainKey.toDecompressedPublicKey(), - ).asRSVLegacyEVM().toHexString().formatHex() - .lowercase() // use lowercase because some dapps cant handle UPPERCASE - } - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - /** - * Returns result of signing prepared to send in WC request - */ - suspend fun signTransaction( - hashToSign: ByteArray, - networkId: String, - type: TransactionType, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - - val command = SignHashCommand( - hash = hashToSign, - walletPublicKey = wallet.publicKey.seedKey, - derivationPath = wallet.publicKey.derivationPath, - ) - return when ( - val result = tangemSdkManager.runTaskAsync( - runnable = command, - cardId = cardId, - preflightReadFilter = null, - ) - ) { - is CompletionResult.Success -> { - val signedHash = result.data.signature - - when (type) { - TransactionType.SOLANA_TX -> { - getSolanaResultString(signedHash) - } - } - } - is CompletionResult.Failure -> { - Timber.e(result.error.customMessage) - null - } - } - } - - /** - * Returns result of signing prepared to send in WC request - */ - suspend fun signTransactions( - hashesToSign: List, - networkId: String, - type: TransactionType, - derivationPath: String?, - cardId: String?, - ): String? { - val blockchain = Blockchain.fromNetworkId(networkId) ?: return null - val wallet = getWalletManager(blockchain, derivationPath)?.wallet ?: return null - val sdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk - - val signer = store.inject(DaggerGraphState::transactionSignerFactory).createTransactionSigner( - cardId = cardId, - sdk = sdk, - twinKey = null, - ) - - return when (val signingResult = signer.sign(hashesToSign, wallet.publicKey)) { - is CompletionResult.Failure -> { - Timber.e(signingResult.error.customMessage) - null - } - is CompletionResult.Success -> { - when (type) { - TransactionType.SOLANA_TX -> { - val result = signingResult.data.mapIndexed { index, bytes -> - byteArrayOf(1) + bytes + hashesToSign[index] - } - getSolanaResultTxHashesString(result) - } - } - } - else -> { - null - } - } - } - - private suspend fun getSelectedWallet(): UserWallet? { - return if (hotWalletFeatureToggles.isHotWalletEnabled) { - userWalletsListRepository.selectedUserWalletSync() - } else { - userWalletsListManager.selectedUserWalletSync - } - } - - private fun getSolanaResultString(signedHash: ByteArray) = "{ signature: \"${signedHash.encodeBase58()}\" }" - - /** - * Build json object - * { - * "transactions": [ - * "signed_tx_hash" - * ] - * } - */ - private fun getSolanaResultTxHashesString(signedHashes: List): String { - val result = JSONObject() - val transactions = JSONArray() - signedHashes.forEach { - transactions.put(it.encodeBase64()) - } - result.put("transactions", transactions) - return result.toString() - } - - private fun getBnbResultString(publicKey: String, signature: String) = - "{\"signature\":\"$signature\",\"publicKey\":\"$publicKey\"}" - - private companion object { - const val HEX_PREFIX = "0x" - const val DEFAULT_MAX_GASLIMIT = 350000 - // TODO remove after [REDACTED_JIRA] - private val MANTLE_FEE_ESTIMATE_MULTIPLIER = BigDecimal("1.8") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt deleted file mode 100644 index b0e2c44579..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.app - -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.tap.domain.walletconnect2.domain.WcBlockchainHelper -import com.tangem.domain.walletconnect.model.legacy.Account - -internal class TangemWcBlockchainHelper : WcBlockchainHelper { - - private val supportedNonEvmBlockchains = setOf(Blockchain.Solana, Blockchain.SolanaTestnet) - - override fun chainIdToNetworkIdOrNull(chainId: String): String? { - val parsedId = chainId.parseId() ?: return null - val blockchain = parsedId.chainIdToBlockchain() - - return blockchain?.toNetworkId() - } - - override fun chainIdsToBlockchains(chainIds: List): List { - return chainIds.mapNotNull { - it.parseId()?.chainIdToBlockchain() - }.distinct() - } - - override fun chainIdToMissingNetworkNameOrNull(chainId: String): String? { - val parsedId = chainId.parseId() ?: return null - val blockchain = parsedId.chainIdToBlockchain() - - return blockchain?.fullName - ?: if (parsedId.first == EVM_NAMESPACE) { - chainId - } else { - parsedId.first.replaceFirstChar(Char::titlecase) - } - } - - override fun networkIdToChainIdOrNull(networkId: String): List { - val blockchain = Blockchain.fromNetworkId(networkId) - val namespace = blockchain?.getCaip2Namespace() ?: return emptyList() - return blockchain.getCaip2ChainIds().map { - "$namespace$CHAIN_SEPARATOR$it" - } - } - - override fun getNamespaceFromFullChainIdOrNull(chainId: String): String? { - val parsed = chainId.split(CHAIN_SEPARATOR) - return parsed.firstOrNull() - } - - override fun chainIdToFullNameOrNull(chainId: String): String? { - val networkId = chainIdToNetworkIdOrNull(chainId) ?: return null - return Blockchain.fromNetworkId(networkId)?.fullName - } - - override fun chainIdsToAccounts( - walletAddress: String, - chainIds: List, - derivationPath: String?, - ): List { - return chainIds.map { chainId -> - Account(chainId, walletAddress, derivationPath) - } - } - - private fun Blockchain.getCaip2ChainIds(): List { - if (this.isEvm()) return listOfNotNull(this.getChainId()?.toString()) - - return when (this) { - /* - * The WC sample application and documentation use a commented out chain ID. However, in real dApps - * uncommented is used. - * Docs: https://docs.walletconnect.com/advanced/multichain/chain-list - * - * */ - Blockchain.Solana -> listOf("5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ") - Blockchain.SolanaTestnet -> listOf("z4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z") - Blockchain.Polkadot -> listOf("91b171bb158e2d3848fa23a9f1c25182") - Blockchain.Tron -> listOf("0x2b6653dc") - else -> emptyList() - } - } - - private fun Blockchain.getCaip2Namespace(): String? { - return when { - this.isEvm() -> EVM_NAMESPACE - supportedNonEvmBlockchains.contains(this) -> this.toNetworkId().substringBefore(TESTNET_SEPARATOR) - else -> null - } - } - - private fun String.parseId(): Pair? { - val parsed = this.split(CHAIN_SEPARATOR) - if (parsed.size != 2) return null - - return parsed[0] to parsed[1] - } - - private fun Pair.chainIdToBlockchain(): Blockchain? { - return when (first) { - EVM_NAMESPACE -> { - second.toIntOrNull() - ?.let(Blockchain::fromChainId) - } - SOLANA_NAMESPACE -> Blockchain.Solana - else -> { - Blockchain.fromNetworkId(networkId = first) - .takeIf(supportedNonEvmBlockchains::contains) - } - } - } - - private companion object { - const val EVM_NAMESPACE = "eip155" - const val SOLANA_NAMESPACE = "solana" - const val CHAIN_SEPARATOR = ":" - const val TESTNET_SEPARATOR = "/" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/WalletConnectEventsHandlerImpl.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/WalletConnectEventsHandlerImpl.kt deleted file mode 100644 index d84263034d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/WalletConnectEventsHandlerImpl.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.app - -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectEventsHandler -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectDialog -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import com.tangem.tap.store -import timber.log.Timber - -internal class WalletConnectEventsHandlerImpl : WalletConnectEventsHandler { - override fun onProposalReceived(proposal: WalletConnectEvents.SessionProposal, networksFormatted: String) { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.SessionProposalDialog( - sessionProposal = proposal, - networks = networksFormatted, - onApprove = { store.dispatchOnMain(WalletConnectAction.ApproveProposal(proposal)) }, - onReject = { store.dispatchOnMain(WalletConnectAction.RejectProposal) }, - ), - ), - ) - } - - override fun onSessionEstablished() { - store.dispatchOnMain(WalletConnectAction.SessionEstablished) - } - - override fun onSessionRejected(error: WalletConnectError) { - store.dispatchOnMain(WalletConnectAction.SessionRejected(error)) - } - - override fun onListOfSessionsUpdated(sessions: List) { - Timber.d("WC2: List of sessions updated. Sessions: $sessions") - store.dispatchOnMain(WalletConnectAction.SessionListUpdated(sessions)) - } - - override fun onSessionRequest(request: WcPreparedRequest) { - store.dispatchOnMain(WalletConnectAction.ShowSessionRequest(request)) - } - - override fun onUnsupportedRequest() { - store.dispatchOnMain(WalletConnectAction.RejectUnsupportedRequest) - } - - override fun onPairConnectError(error: Throwable) { - store.dispatchOnMain(WalletConnectAction.PairConnectErrorAction(error)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt deleted file mode 100644 index 7d1b854d10..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt +++ /dev/null @@ -1,686 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.data - -import android.app.Application -import arrow.core.flatten -import com.reown.android.Core -import com.reown.android.CoreClient -import com.reown.android.relay.ConnectionType -import com.reown.walletkit.client.Wallet -import com.reown.walletkit.client.WalletKit -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.pair.UnsupportedDApps -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletconnect.WcPairService -import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles -import com.tangem.tap.common.analytics.events.WalletConnect -import com.tangem.tap.domain.walletconnect2.app.TangemWcBlockchainHelper -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WcJrpcMethods -import com.tangem.tap.domain.walletconnect2.domain.WcJrpcRequestsDeserializer -import com.tangem.tap.domain.walletconnect2.domain.WcRequest -import com.tangem.tap.domain.walletconnect2.domain.models.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emptyFlow -import timber.log.Timber - -internal class DefaultLegacyWalletConnectRepositoryFacade constructor( - private val stub: LegacyWalletConnectRepositoryStub, - private val legacy: DefaultLegacyWalletConnectRepository, - private val walletConnectFeatureToggles: WalletConnectFeatureToggles, - private val wcInitializeUseCase: WcInitializeUseCase, - private val wcPairService: WcPairService, -) : LegacyWalletConnectRepository { - private val isNewWc by lazy { - walletConnectFeatureToggles.isRedesignedWalletConnectEnabled - } - override val events: Flow by lazy { - if (isNewWc) stub.events else legacy.events - } - override val activeSessions: Flow> by lazy { - if (isNewWc) stub.activeSessions else legacy.activeSessions - } - override val currentSessions: List by lazy { - if (isNewWc) stub.currentSessions else legacy.currentSessions - } - - override fun init(projectId: String) { - if (isNewWc) { - wcInitializeUseCase.init(projectId) - } else { - legacy.init(projectId) - } - } - - override fun setUserNamespaces(userNamespaces: Map>) { - if (isNewWc) stub.setUserNamespaces(userNamespaces) else legacy.setUserNamespaces(userNamespaces) - } - - override fun updateSessions() { - if (isNewWc) stub.updateSessions() else legacy.updateSessions() - } - - override fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) { - val src = when (source) { - SourceType.QR -> WcPairRequest.Source.QR - SourceType.DEEPLINK -> WcPairRequest.Source.DEEPLINK - SourceType.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD - SourceType.ETC -> WcPairRequest.Source.ETC - } - if (isNewWc) { - wcPairService.pair(WcPairRequest(uri = uri, source = src, userWalletId = userWalletId)) - } else { - legacy.pair(userWalletId = userWalletId, uri = uri, source = source) - } - } - - override fun disconnect(topic: String) { - if (isNewWc) stub.disconnect(topic) else legacy.disconnect(topic) - } - - override fun approve(userNamespaces: Map>, blockchainNames: List) { - if (isNewWc) stub.approve(userNamespaces, blockchainNames) else legacy.approve(userNamespaces, blockchainNames) - } - - override fun reject() { - if (isNewWc) stub.reject() else legacy.reject() - } - - override fun sendRequest(requestData: RequestData, result: String) { - if (isNewWc) stub.sendRequest(requestData, result) else legacy.sendRequest(requestData, result) - } - - override fun rejectRequest(requestData: RequestData, error: WalletConnectError) { - if (isNewWc) stub.rejectRequest(requestData, error) else legacy.rejectRequest(requestData, error) - } - - override fun cancelRequest(topic: String, id: Long, message: String) { - if (isNewWc) stub.cancelRequest(topic, id, message) else legacy.cancelRequest(topic, id, message) - } -} - -internal class LegacyWalletConnectRepositoryStub : LegacyWalletConnectRepository { - override val events: Flow = emptyFlow() - override val activeSessions: Flow> = emptyFlow() - override val currentSessions: List = listOf() - - override fun init(projectId: String) = Unit - - override fun setUserNamespaces(userNamespaces: Map>) = Unit - - override fun updateSessions() = Unit - - override fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) = Unit - - override fun disconnect(topic: String) = Unit - - override fun approve(userNamespaces: Map>, blockchainNames: List) = Unit - - override fun reject() = Unit - - override fun sendRequest(requestData: RequestData, result: String) = Unit - - override fun rejectRequest(requestData: RequestData, error: WalletConnectError) = Unit - - override fun cancelRequest(topic: String, id: Long, message: String) = Unit -} - -@Suppress("LargeClass") -internal class DefaultLegacyWalletConnectRepository( - private val application: Application, - private val wcRequestDeserializer: WcJrpcRequestsDeserializer, - private val analyticsHandler: AnalyticsEventHandler, -) : LegacyWalletConnectRepository { - - private var sessionProposal: Wallet.Model.SessionProposal? = null - private var userNamespaces: Map>? = null - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - private val _events: MutableSharedFlow = MutableSharedFlow() - override val events: Flow = _events - - private val _activeSessions: MutableSharedFlow> = MutableSharedFlow() - override val activeSessions: Flow> = _activeSessions - private val blockchainHelper by lazy { TangemWcBlockchainHelper() } - - override var currentSessions: List = emptyList() - private set - - /** - * @param projectId Project ID at https://cloud.walletconnect.com/ - */ - override fun init(projectId: String) { - val relayUrl = "relay.walletconnect.com" - val serverUrl = "wss://$relayUrl?projectId=$projectId" - val connectionType = ConnectionType.AUTOMATIC - - val appMetaData = Core.Model.AppMetaData( - name = "Tangem", - description = "Tangem Wallet", - url = "tangem.com", - icons = listOf( - "https://user-images.githubusercontent.com/24321494/124071202-72a00900-da58-11eb-935a-dcdab21de52b.png", - ), - redirect = "kotlin-wallet-wc:/request", // Custom Redirect URI - ) - - CoreClient.initialize( - relayServerUrl = serverUrl, - connectionType = connectionType, - application = application, - metaData = appMetaData, - ) { error -> - Timber.e("Error while initializing client: $error") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ExternalApprovalError(error.throwable.message), - ), - ) - } - } - - WalletKit.initialize( - Wallet.Params.Init(core = CoreClient), - onSuccess = { - val walletDelegate = defineWalletDelegate() - WalletKit.setWalletDelegate(walletDelegate) - }, - onError = { error -> - Timber.e("Error while initializing Web3Wallet: $error") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ExternalApprovalError(error.throwable.message), - ), - ) - } - }, - ) - } - - private fun defineWalletDelegate(): WalletKit.WalletDelegate { - return object : WalletKit.WalletDelegate { - - @Suppress("LongMethod") - override fun onSessionProposal( - sessionProposal: Wallet.Model.SessionProposal, - verifyContext: Wallet.Model.VerifyContext, - ) { - // Triggered when wallet receives the session proposal sent by a Dapp - Timber.i("sessionProposal: $sessionProposal") - this@DefaultLegacyWalletConnectRepository.sessionProposal = sessionProposal - - if (sessionProposal.name in UnsupportedDApps.list) { - Timber.i("Unsupported DApp") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.UnsupportedDApp, - ), - ) - } - return - } - - val missingNetworks = findMissingNetworks( - namespaces = sessionProposal.requiredNamespaces, - userNamespaces = this@DefaultLegacyWalletConnectRepository.userNamespaces ?: emptyMap(), - ) - - if (missingNetworks.isNotEmpty()) { - Timber.i("Not added blockchains: $missingNetworks") - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ApprovalErrorMissingNetworks(missingNetworks.toList()), - ), - ) - } - return - } - - val optionalMissingNetwork = findMissingNetworks( - namespaces = sessionProposal.optionalNamespaces, - userNamespaces = this@DefaultLegacyWalletConnectRepository.userNamespaces ?: emptyMap(), - ) - - val optionalWithoutMissingNetworks = removeMissingNetworks( - namespaces = sessionProposal.optionalNamespaces, - userNamespaces = this@DefaultLegacyWalletConnectRepository.userNamespaces ?: emptyMap(), - ) - - // for cases when optionalNamespaces is not empty but we doesn't support none of them - if (optionalMissingNetwork.isNotEmpty() && - optionalWithoutMissingNetworks.isEmpty() && - sessionProposal.requiredNamespaces.isEmpty() // if requiredNamespaces is not empty we can connect - ) { - Timber.i("Not added optional blockchains: $optionalMissingNetwork") - - val unsupportedNetworks = sessionProposal.optionalNamespaces.values - .flatMap { it.chains ?: emptyList() } - .filter { blockchainHelper.chainIdToNetworkIdOrNull(it) == null } - - scope.launch { - val error = if (unsupportedNetworks.isNotEmpty()) { - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ApprovalErrorUnsupportedNetwork(unsupportedNetworks), - ) - } else { - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ApprovalErrorMissingNetworks(optionalMissingNetwork.toList()), - ) - } - _events.emit(error) - } - return - } - - val requiredChainIds = sessionProposal.requiredNamespaces.values.flatMap { it.chains ?: emptyList() } - val optionalChainIds = optionalWithoutMissingNetworks.toList() - val networks = (requiredChainIds + optionalChainIds) - .mapNotNull { blockchainHelper.chainIdToFullNameOrNull(it) } - .distinct() - analyticsHandler.send(WalletConnect.DAppConnectionRequested(networks)) - - scope.launch { - _events.emit( - WalletConnectEvents.SessionProposal( - sessionProposal.name, - sessionProposal.description, - sessionProposal.url, - sessionProposal.icons, - requiredChainIds, - optionalChainIds, - ), - ) - } - } - - override fun onSessionRequest( - sessionRequest: Wallet.Model.SessionRequest, - verifyContext: Wallet.Model.VerifyContext, - ) { - // Triggered when a Dapp sends SessionRequest to sign a transaction or a message - Timber.i("sessionRequest: $sessionRequest") - val request = wcRequestDeserializer.deserialize( - method = sessionRequest.request.method, - params = sessionRequest.request.params, - ) - Timber.i("sessionRequestParsed: $request") - - when (request) { - is WcRequest.AddChain -> { - // we can send approval automatically, because in WC 2.0 the list of chains is approved when - // initial connection is established - sendRequest( - RequestData( - topic = sessionRequest.topic, - requestId = sessionRequest.request.id, - blockchain = sessionRequest.chainId.toString(), - method = WcJrpcMethods.WALLET_ADD_ETHEREUM_CHAIN.code, - ), - result = "", - ) - } - else -> { - val event = WalletConnect.SignatureRequestReceived( - WalletConnect.RequestHandledParams( - dAppName = sessionRequest.peerMetaData?.name ?: "", - dAppUrl = sessionRequest.peerMetaData?.url ?: "", - methodName = sessionRequest.request.method, - blockchain = sessionRequest.chainId - ?.let { blockchainHelper.chainIdToNetworkIdOrNull(it) } ?: "", - ), - ) - analyticsHandler.send(event) - scope.launch { - _events.emit( - WalletConnectEvents.SessionRequest( - request = request, - chainId = sessionRequest.chainId, - topic = sessionRequest.topic, - id = sessionRequest.request.id, - metaUrl = sessionRequest.peerMetaData?.url ?: "", - metaName = sessionRequest.peerMetaData?.name ?: "", - method = sessionRequest.request.method, - ), - ) - } - } - } - } - - override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { - // Triggered when the session is deleted by the peer - if (sessionDelete is Wallet.Model.SessionDelete.Success) { - scope.launch { - _events.emit(WalletConnectEvents.SessionDeleted(sessionDelete.topic)) - updateSessionsInternal().join() - } - } - Timber.i("onSessionDelete: $sessionDelete") - } - - override fun onSessionExtend(session: Wallet.Model.Session) { - Timber.i("onSessionExtend: $session") - } - - override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { - // Triggered when wallet receives the session settlement response from Dapp - Timber.i("onSessionSettleResponse: $settleSessionResponse") - if (settleSessionResponse is Wallet.Model.SettledSessionResponse.Result) { - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalSuccess( - topic = settleSessionResponse.session.topic, - accounts = userNamespaces?.flatMap { it.value } ?: emptyList(), - ), - ) - updateSessionsInternal().join() - } - } - } - - override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) { - // Triggered when wallet receives the session update response from Dapp - Timber.i("onSessionUpdateResponse: $sessionUpdateResponse") - updateSessionsInternal() - } - - override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) { - // Triggered whenever the connection state is changed - Timber.i("onConnectionStateChange: $state") - if (state.isAvailable) updateSessionsInternal() - } - - override fun onError(error: Wallet.Model.Error) { - // Triggered whenever there is an issue inside the SDK - Timber.i("onError: $error") - } - } - } - - override fun setUserNamespaces(userNamespaces: Map>) { - this.userNamespaces = userNamespaces - } - - override fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) { - analyticsHandler.send(WalletConnect.NewSessionInitiated(source = source)) - WalletKit.pair( - params = Wallet.Params.Pair(uri), - onSuccess = { - Timber.i("Paired successfully: $it") - }, - onError = { - Timber.e("Error while pairing: $it") - analyticsHandler.send(WalletConnect.SessionFailed) - scope.launch { - _events.emit( - WalletConnectEvents.PairConnectError(it.throwable), - ) - } - }, - ) - } - - override fun approve(userNamespaces: Map>, blockchainNames: List) { - val sessionProposal: Wallet.Model.SessionProposal = requireNotNull(this.sessionProposal) - - val userChains = userNamespaces.flatMap { namespace -> - namespace.value.map { it.chainId to "${it.chainId}:${it.walletAddress}" } - }.groupBy { pair -> pair.first } - .mapValues { entry -> entry.value.map { pair -> pair.second }.toSet() } - - val preparedRequiredNamespaces = sessionProposal.requiredNamespaces - .map { requiredNamespace -> - val accountsRequired = requiredNamespace.value.chains - ?.mapNotNull { chain -> userChains[chain] } - ?.flatten() ?: emptyList() - val optionalNamespace = sessionProposal.optionalNamespaces[requiredNamespace.key] - val accountsOptional = optionalNamespace?.chains - ?.mapNotNull { chain -> userChains[chain] } - ?.flatten() ?: emptyList() - - val methods = (requiredNamespace.value.methods + (optionalNamespace?.methods ?: emptyList())) - .distinct() - requiredNamespace.key to Wallet.Model.Namespace.Session( - accounts = (accountsRequired + accountsOptional).distinct(), - methods = methods, - events = requiredNamespace.value.events, - ) - }.toMap() - - val sessionApproval = Wallet.Params.SessionApprove( - proposerPublicKey = sessionProposal.proposerPublicKey, - namespaces = preparedRequiredNamespaces.ifEmpty { - sessionProposal.createPreparedOptionalNamespaces(userChains) - }.filterValues { it.accounts.isNotEmpty() && it.chains?.isNotEmpty() == true }, - ) - - Timber.i("Session approval is prepared for sending: $sessionApproval") - - WalletKit.approveSession( - params = sessionApproval, - onSuccess = { - Timber.i("Approved successfully: $it") - analyticsHandler.send( - WalletConnect.DAppConnected( - dAppName = sessionProposal.name, - dAppUrl = sessionProposal.url, - blockchainNames = blockchainNames, - ), - ) - }, - onError = { - Timber.e("Error while approving: $it") - analyticsHandler.send( - WalletConnect.DAppConnectionFailed( - dAppName = sessionProposal.name, - dAppUrl = sessionProposal.url, - blockchainNames = blockchainNames, - ), - ) - scope.launch { - _events.emit( - WalletConnectEvents.SessionApprovalError( - WalletConnectError.ExternalApprovalError(it.throwable.message), - ), - ) - } - }, - ) - } - - private fun Wallet.Model.SessionProposal.createPreparedOptionalNamespaces( - userChains: Map>, - ): Map { - return optionalNamespaces - .map { optionalNamespace -> - val accountsOptional = optionalNamespace.value.chains - ?.mapNotNull { chain -> userChains[chain] } - ?.flatten() ?: emptyList() - - val methods = optionalNamespace.value.methods - optionalNamespace.key to Wallet.Model.Namespace.Session( - accounts = accountsOptional.distinct(), - methods = methods, - chains = userChains.keys - .filter { it.startsWith(optionalNamespace.key) } - .toList(), - events = optionalNamespace.value.events, - ) - } - .toMap() - } - - override fun sendRequest(requestData: RequestData, result: String) { - val session = currentSessions.find { it.topic == requestData.topic } - // Add Ethereum Chain method is processed without user input, skip logging it - if (requestData.method != WcJrpcMethods.WALLET_ADD_ETHEREUM_CHAIN.code) { - analyticsHandler.send( - WalletConnect.SignatureRequestHandled( - WalletConnect.RequestHandledParams( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - methodName = requestData.method, - blockchain = requestData.blockchain, - ), - ), - ) - } - - val params = Wallet.Params.SessionRequestResponse( - sessionTopic = requestData.topic, - jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult( - id = requestData.requestId, - result = result, - ), - ) - Timber.i("Session request response: $params") - - WalletKit.respondSessionRequest( - params = params, - onSuccess = { response -> - Timber.i("Session request responded successfully: $response") - }, - onError = { error -> - Timber.e(error.throwable, "Error while responging session request") - - val handledParams = WalletConnect.RequestHandledParams( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - methodName = requestData.method, - blockchain = requestData.blockchain, - errorCode = WalletConnectError.ValidationError.error, - errorDescription = error.throwable.message, - ) - analyticsHandler.send(WalletConnect.SignatureRequestFailed(handledParams)) - }, - ) - } - - override fun rejectRequest(requestData: RequestData, error: WalletConnectError) { - val session = currentSessions.find { it.topic == requestData.topic } - - analyticsHandler.send( - WalletConnect.SignatureRequestFailed( - WalletConnect.RequestHandledParams( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - methodName = requestData.method, - blockchain = requestData.blockchain, - errorCode = error.toString(), - ), - ), - ) - - cancelRequest(requestData.topic, requestData.requestId, error.error) - } - - override fun cancelRequest(topic: String, id: Long, message: String) { - WalletKit.respondSessionRequest( - params = Wallet.Params.SessionRequestResponse( - sessionTopic = topic, - jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( - id = id, - code = 0, - message = message, - ), - ), - onSuccess = {}, - onError = {}, - ) - } - - override fun reject() { - WalletKit.rejectSession( - params = Wallet.Params.SessionReject( - proposerPublicKey = sessionProposal?.proposerPublicKey ?: "", - reason = "", - ), - onSuccess = { - Timber.i("Rejected successfully: $it") - }, - onError = { - Timber.e("Error while rejecting: $it") - }, - ) - } - - override fun disconnect(topic: String) { - val session = currentSessions.find { it.topic == topic } - WalletKit.disconnectSession( - params = Wallet.Params.SessionDisconnect(topic), - onSuccess = { - analyticsHandler.send( - WalletConnect.SessionDisconnected( - dAppName = session?.name ?: "", - dAppUrl = session?.url ?: "", - ), - ) - updateSessionsInternal() - Timber.i("Disconnected successfully: $it") - }, - onError = { - Timber.e("Error while disconnecting: $it") - }, - ) - } - - fun send(topic: String, id: Long, data: String) { - WalletKit.respondSessionRequest( - params = Wallet.Params.SessionRequestResponse( - sessionTopic = topic, - jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult( - id = id, - result = data, - ), - ), - onError = {}, - onSuccess = {}, - ) - } - - override fun updateSessions() { - updateSessionsInternal() - } - - private fun updateSessionsInternal(): Job = scope.launch { - val availableSessions = WalletKit.getListOfActiveSessions() - .map { - WalletConnectSession( - topic = it.topic, - icon = it.metaData?.icons?.firstOrNull(), - name = it.metaData?.name, - url = it.metaData?.url, - ) - } - Timber.i("Available sessions: $availableSessions") - currentSessions = availableSessions - _activeSessions.emit(availableSessions) - } - - private fun findMissingNetworks( - namespaces: Map, - userNamespaces: Map>, - ): Collection { - val requiredChains = namespaces.values.flatMap { it.chains ?: emptyList() } - val userChains = userNamespaces.flatMap { it.value.map { account -> account.chainId } } - return requiredChains.subtract(userChains.toSet()) - } - - private fun removeMissingNetworks( - namespaces: Map, - userNamespaces: Map>, - ): Collection { - val wcProvidedChains = namespaces.values.flatMap { it.chains ?: emptyList() } - val userChains = userNamespaces.flatMap { it.value.map { account -> account.chainId } } - return wcProvidedChains.intersect(userChains.toSet()) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt deleted file mode 100644 index 108492f2de..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.data - -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import com.tangem.datasource.files.FileReader -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.domain.walletconnect.model.legacy.Session -import timber.log.Timber - -internal class DefaultWalletConnectSessionsRepository( - private val moshi: Moshi, - private val fileReader: FileReader, -) : - WalletConnectSessionsRepository { - - private val sessionsAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(List::class.java, Session::class.java), - ) - - override suspend fun loadSessions(userWallet: String): List { - return try { - val fileContent = fileReader.readFile(getFileNameForUserWallet(userWallet)) - sessionsAdapter.fromJson(fileContent) ?: emptyList() - } catch (exception: Exception) { - Timber.d(exception) - emptyList() - } - } - - override suspend fun saveSession(userWallet: String, session: Session) { - val updatedList = loadSessions(userWallet).plus(session) - writeSessionToFile(sessions = updatedList, userWallet = userWallet) - } - - override suspend fun removeSession(userWallet: String, topic: String) { - val updatedList = loadSessions(userWallet).filterNot { it.topic == topic } - writeSessionToFile(sessions = updatedList, userWallet = userWallet) - } - - private fun writeSessionToFile(sessions: List, userWallet: String) { - val serialized = sessionsAdapter.toJson(sessions) - try { - fileReader.rewriteFile(serialized, getFileNameForUserWallet(userWallet)) - } catch (exception: Exception) { - Timber.e(exception) - } - } - - companion object { - private const val FILE_NAME_PREFIX = "wc_2" - - private fun getFileNameForUserWallet(userWallet: String): String { - return "$FILE_NAME_PREFIX-${userWallet.uppercase()}" - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt deleted file mode 100644 index 6c9e9f189d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.di - -import android.app.Application -import com.squareup.moshi.Moshi -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.datasource.di.SdkMoshi -import com.tangem.datasource.files.FileReader -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.WcPairService -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles -import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper -import com.tangem.tap.domain.walletconnect2.app.TangemWcBlockchainHelper -import com.tangem.tap.domain.walletconnect2.app.WalletConnectEventsHandlerImpl -import com.tangem.tap.domain.walletconnect2.data.DefaultLegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.data.DefaultLegacyWalletConnectRepositoryFacade -import com.tangem.tap.domain.walletconnect2.data.DefaultWalletConnectSessionsRepository -import com.tangem.tap.domain.walletconnect2.data.LegacyWalletConnectRepositoryStub -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor -import com.tangem.tap.domain.walletconnect2.domain.WcJrpcRequestsDeserializer -import com.tangem.tap.domain.walletconnect2.toggles.DefaultWalletConnectFeatureToggles -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object WalletConnectInteractorModule { - - @Provides - @Singleton - fun provideWalletConnectInteractor( - wcRepository: LegacyWalletConnectRepository, - wcSessionsRepository: WalletConnectSessionsRepository, - currenciesRepository: CurrenciesRepository, - walletManagersFacade: WalletManagersFacade, - walletConnectFeatureToggles: WalletConnectFeatureToggles, - coroutineDispatcherProvider: CoroutineDispatcherProvider, - getSelectedWalletUseCase: GetSelectedWalletUseCase, - ): WalletConnectInteractor { - return WalletConnectInteractor( - handler = WalletConnectEventsHandlerImpl(), - walletConnectRepository = wcRepository, - sessionsRepository = wcSessionsRepository, - sdkHelper = WalletConnectSdkHelper(), - blockchainHelper = TangemWcBlockchainHelper(), - currenciesRepository = currenciesRepository, - walletManagersFacade = walletManagersFacade, - getSelectedWalletUseCase = getSelectedWalletUseCase, - dispatchers = coroutineDispatcherProvider, - walletConnectFeatureToggles = walletConnectFeatureToggles, - ) - } -} - -@Module -@InstallIn(SingletonComponent::class) -internal object WalletConnectModule { - - @Provides - @Singleton - fun provideWalletConnectFeatureToggles(featureTogglesManager: FeatureTogglesManager): WalletConnectFeatureToggles { - return DefaultWalletConnectFeatureToggles(featureTogglesManager) - } - - @Provides - @Singleton - fun provideWalletConnectRepository( - application: Application, - wcRequestDeserializer: WcJrpcRequestsDeserializer, - analyticsHandler: AnalyticsEventHandler, - walletConnectFeatureToggles: WalletConnectFeatureToggles, - wcInitializeUseCase: WcInitializeUseCase, - wcPairService: WcPairService, - ): LegacyWalletConnectRepository { - val legacy = DefaultLegacyWalletConnectRepository( - application = application, - wcRequestDeserializer = wcRequestDeserializer, - analyticsHandler = analyticsHandler, - ) - val stub = LegacyWalletConnectRepositoryStub() - return DefaultLegacyWalletConnectRepositoryFacade( - stub, - legacy, - walletConnectFeatureToggles, - wcInitializeUseCase, - wcPairService, - ) - } - - @Provides - @Singleton - fun provideWalletConnectSessionsRepository( - @SdkMoshi moshi: Moshi, - fileReader: FileReader, - ): WalletConnectSessionsRepository { - return DefaultWalletConnectSessionsRepository( - moshi = moshi, - fileReader = fileReader, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt deleted file mode 100644 index 55b4899aa9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.tap.domain.walletconnect2.domain.models.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType -import kotlinx.coroutines.flow.Flow - -interface LegacyWalletConnectRepository { - - val events: Flow - - val activeSessions: Flow> - - val currentSessions: List - - fun init(projectId: String) - - fun setUserNamespaces(userNamespaces: Map>) - - fun updateSessions() - - fun pair(userWalletId: UserWalletId, uri: String, source: SourceType) - - fun disconnect(topic: String) - - fun approve(userNamespaces: Map>, blockchainNames: List) - - fun reject() - - fun sendRequest(requestData: RequestData, result: String) - - fun rejectRequest(requestData: RequestData, error: WalletConnectError) - - fun cancelRequest(topic: String, id: Long, message: String = "") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectEventsHandler.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectEventsHandler.kt deleted file mode 100644 index b9414d247e..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectEventsHandler.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen - -interface WalletConnectEventsHandler { - fun onProposalReceived(proposal: WalletConnectEvents.SessionProposal, networksFormatted: String) - - fun onSessionEstablished() - - fun onSessionRejected(error: WalletConnectError) - - fun onListOfSessionsUpdated(sessions: List) - - fun onSessionRequest(request: WcPreparedRequest) - - fun onUnsupportedRequest() - - fun onPairConnectError(error: Throwable) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt deleted file mode 100644 index 395ad23885..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt +++ /dev/null @@ -1,457 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import arrow.core.flatten -import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.domain.walletconnect.model.legacy.Session -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.filterNotNull -import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper -import com.tangem.tap.domain.walletconnect2.domain.models.* -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import com.tangem.tap.store -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import timber.log.Timber -import java.util.Stack - -@Suppress("LargeClass", "LongParameterList") -class WalletConnectInteractor( - private val handler: WalletConnectEventsHandler, - private val walletConnectRepository: LegacyWalletConnectRepository, - private val sessionsRepository: WalletConnectSessionsRepository, - private val sdkHelper: WalletConnectSdkHelper, - private val dispatchers: CoroutineDispatcherProvider, - private val walletManagersFacade: WalletManagersFacade, - private val currenciesRepository: CurrenciesRepository, - private val walletConnectFeatureToggles: WalletConnectFeatureToggles, - private val getSelectedWalletUseCase: GetSelectedWalletUseCase, - val blockchainHelper: WcBlockchainHelper, -) { - private val isNewWc by lazy { walletConnectFeatureToggles.isRedesignedWalletConnectEnabled } - - private var isWalletConnectReadyForDeepLinks = false - - private val wcScope = CoroutineScope( - SupervisorJob() + dispatchers.io + CoroutineExceptionHandler { _, throwable -> - Timber.e("CoroutineException: from: LISTENER SCOPE, exception: $throwable") - }, - ) - - private val listenerScope = CoroutineScope( - SupervisorJob() + dispatchers.io + CoroutineExceptionHandler { _, throwable -> - Timber.e("CoroutineException: from: LISTENER SCOPE, exception: $throwable") - }, - ) - - /** Stack of deeplinks to handle if user wallet is not selected or cryptocurrency statuses are not available */ - private val deeplinkStack: Stack = Stack() - - private val events = walletConnectRepository.events - private val sessions = walletConnectRepository.activeSessions - - private var userWalletId: String = "" - private var cardId: String? = null - - private var currentRequest: WalletConnectEvents.SessionRequest? = null - private val sessionRequestConverter = WcSessionRequestConverter( - blockchainHelper = blockchainHelper, - sessionsRepository = sessionsRepository, - sdkHelper = sdkHelper, - ) - - init { - getSelectedWalletUseCase().onRight { userWalletFlow -> - userWalletFlow - .conflate() - .distinctUntilChanged() - .onEach(::initWithWallet) - .flowOn(dispatchers.io) - .launchIn(wcScope) - } - } - - private fun initWithWallet(userWallet: UserWallet) { - if (isNewWc) return - if (userWallet.isMultiCurrency) { - Timber.i("WalletConnect: initialize and setup networks for ${userWallet.walletId}") - startListeningWc(userWallet.walletId.stringValue, getCardId(userWallet)) - subscribeOnCurrenciesUpdates(userWallet) - } - } - - private fun subscribeOnCurrenciesUpdates(userWallet: UserWallet) { - currenciesRepository.getMultiCurrencyWalletCurrenciesUpdates(userWallet.walletId) - .conflate() - .distinctUntilChanged() - .onEach { currencies -> - setupUserChains(userWallet, currencies) - } - .flowOn(dispatchers.io) - .launchIn(wcScope) - } - - private suspend fun setupUserChains(userWallet: UserWallet, currencies: List) { - val accounts = getAccountsForWc( - userWallet = userWallet, - networks = currencies.map { it.network }.distinct(), - ) - setUserChains(accounts) - handleDeeplinkStack(accounts) - } - - private fun startListeningWc(userWalletId: String, cardId: String?) { - this.userWalletId = userWalletId - this.cardId = cardId - listenerScope.coroutineContext.cancelChildren() - listenerScope.launch { - launch { subscribeToEvents() } - launch { subscribeToSessions() } - walletConnectRepository.updateSessions() - } - } - - private fun setUserChains(accounts: List) { - val userNamespaces: Map> = accounts - .groupBy { account -> - blockchainHelper.getNamespaceFromFullChainIdOrNull(account.chainId) - ?.let { NetworkNamespace(it) } - }.filterNotNull() - walletConnectRepository.setUserNamespaces(userNamespaces) - } - - private fun handleDeeplinkStack(accounts: List) { - runCatching { - if (accounts.isEmpty()) return - isWalletConnectReadyForDeepLinks = true - if (deeplinkStack.empty()) return - val lastDeeplink = deeplinkStack.pop() - val action = WalletConnectAction - .OpenSession( - wcUri = lastDeeplink, - source = WalletConnectAction.OpenSession.SourceType.DEEPLINK, - userWalletId = UserWalletId(userWalletId), - ) - store.dispatchOnMain(action) - }.onFailure { - Timber.e("WC deeplink handling failed. $it") - } - } - - private suspend fun subscribeToEvents() { - events - .onEach { wcEvent -> - Timber.i("WalletConnect: event: $wcEvent") - when (wcEvent) { - is WalletConnectEvents.SessionProposal -> { - val unsupportedNetworks = wcEvent.requiredChainIds - .filter { blockchainHelper.chainIdToNetworkIdOrNull(it) == null } - if (unsupportedNetworks.isNotEmpty()) { - val error = WalletConnectError.ApprovalErrorUnsupportedNetwork(unsupportedNetworks) - handler.onSessionRejected(error) - return@onEach - } - val networksFormatted = (wcEvent.requiredChainIds + wcEvent.optionalChainIds) - .mapNotNull { blockchainHelper.chainIdToFullNameOrNull(it) } - .distinct() - .toString() - handler.onProposalReceived(proposal = wcEvent, networksFormatted = networksFormatted) - } - is WalletConnectEvents.SessionApprovalError -> { - val error = when (wcEvent.error) { - is WalletConnectError.ApprovalErrorMissingNetworks -> { - val missingNetworks = wcEvent.error.missingChains.mapNotNull { - blockchainHelper.chainIdToMissingNetworkNameOrNull(it) - } - - WalletConnectError.ApprovalErrorAddNetwork(missingNetworks) - } - else -> wcEvent.error - } - handler.onSessionRejected(error) - } - is WalletConnectEvents.SessionApprovalSuccess -> { - sessionsRepository.saveSession( - userWallet = userWalletId, - session = Session( - accounts = wcEvent.accounts, - topic = wcEvent.topic, - ), - ) - walletConnectRepository.updateSessions() - handler.onSessionEstablished() - } - is WalletConnectEvents.SessionDeleted -> { - sessionsRepository.removeSession(userWalletId, wcEvent.topic) - } - is WalletConnectEvents.SessionRequest -> { - handleRequest(wcEvent) - } - is WalletConnectEvents.PairConnectError -> { - handler.onPairConnectError(wcEvent.error) - } - } - } - .flowOn(dispatchers.io) - .collect() - } - - private suspend fun subscribeToSessions() { - sessions - .onEach { listOfSessions -> - val relevantTopics = getTopicsForUserWallet(userWalletId, sessionsRepository) - val filteredSessions = filterSessionsForUserWallet(listOfSessions, relevantTopics) - handler.onListOfSessionsUpdated(filteredSessions) - } - .flowOn(dispatchers.io) - .collect() - } - - private fun filterSessionsForUserWallet( - availableSessions: List, - relevantTopics: List, - ): List { - return availableSessions.filter { relevantTopics.contains(it.topic) } - .map { - WcSessionForScreen( - description = it.name ?: "", - sessionId = it.topic, - ) - } - } - - private suspend fun getTopicsForUserWallet( - userWalletId: String, - repository: WalletConnectSessionsRepository, - ): List { - return repository.loadSessions(userWalletId).map { it.topic } - } - - fun approveSessionProposal(accounts: List) { - if (isNewWc) return - Timber.i("Approve session proposal: $accounts") - val userNamespaces: Map> = accounts - .groupBy { account -> - blockchainHelper.getNamespaceFromFullChainIdOrNull(account.chainId) - ?.let { NetworkNamespace(it) } - }.filterNotNull() - - val blockchainNames = blockchainHelper.chainIdsToBlockchains(userNamespaces.values.flatten().map { it.chainId }) - .map { it.fullName } - walletConnectRepository.approve( - userNamespaces = userNamespaces, - blockchainNames = blockchainNames, - ) - } - - fun rejectSessionProposal() { - if (isNewWc) return - Timber.i("Reject session proposal") - walletConnectRepository.reject() - } - - fun disconnectSession(topic: String) { - if (isNewWc) return - Timber.i("Disconnect session: $topic") - walletConnectRepository.disconnect(topic) - } - - fun cancelRequest(topic: String, id: Long) { - if (isNewWc) return - Timber.i("Cancel request: $topic, $id") - walletConnectRepository.cancelRequest(topic, id) - } - - private suspend fun handleRequest(sessionRequest: WalletConnectEvents.SessionRequest) { - val error: WalletConnectError? = when { - sessionsRepository.loadSessions(userWalletId).none { it.topic == sessionRequest.topic } -> { - WalletConnectError.WrongUserWallet - } - sessionRequest.request is WcRequest.CustomRequest -> { - WalletConnectError.UnsupportedMethod - } - else -> { - null - } - } - val networkId = sessionRequest.chainId?.let { blockchainHelper.chainIdToNetworkIdOrNull(it) } ?: "" - val requestData = RequestData( - topic = sessionRequest.topic, - requestId = sessionRequest.id, - blockchain = networkId, - method = sessionRequest.method, - ) - - if (error != null) { - walletConnectRepository.rejectRequest(requestData, error) - return - } - - when (sessionRequest.request) { - is WcRequest.BnbCancel -> Unit - is WcRequest.BnbTxConfirm -> walletConnectRepository.sendRequest( - requestData = requestData, - result = "", - ) - else -> { - currentRequest = sessionRequest - - val data = prepareRequestData(sessionRequest).getOrElse { e -> - val wrappedError = e as? WalletConnectError ?: WalletConnectError.UnknownError( - message = e.localizedMessage ?: "Unknown error", - ) - - walletConnectRepository.rejectRequest(requestData, wrappedError) - handler.onSessionRejected(wrappedError) - return - } - - handler.onSessionRequest(data) - } - } - } - - suspend fun continueWithRequest(request: WcPreparedRequest) { - if (isNewWc) return - val currentRequest = this.currentRequest - if (currentRequest == null || request.topic != currentRequest.topic) return - - val networkId = blockchainHelper.chainIdToNetworkIdOrNull(currentRequest.chainId.orEmpty()) ?: return - val signingResultData = when (request) { - is WcPreparedRequest.BnbTransaction -> sdkHelper.signBnbTransaction( - data = request.preparedRequestData.data.data, - networkId = networkId, - derivationPath = request.derivationPath, - cardId = cardId, - ) - is WcPreparedRequest.EthTransaction -> sdkHelper.completeTransaction( - data = request.preparedRequestData, - cardId = cardId, - ) - is WcPreparedRequest.EthSign -> sdkHelper.signPersonalMessage( - hashToSign = request.preparedRequestData.hash, - networkId = networkId, - type = request.preparedRequestData.type, - derivationPath = request.derivationPath, - cardId = cardId, - ) - is WcPreparedRequest.SolanaSignTransaction -> sdkHelper.signTransaction( - hashToSign = request.preparedRequestData.hashToSign, - networkId = networkId, - type = request.preparedRequestData.type, - derivationPath = request.derivationPath, - cardId = cardId, - ) - is WcPreparedRequest.SolanaSignMultipleTransactions -> sdkHelper.signTransactions( - hashesToSign = request.preparedRequestData.hashesToSign, - networkId = networkId, - type = request.preparedRequestData.type, - derivationPath = request.derivationPath, - cardId = cardId, - ) - } - - val requestData = RequestData( - topic = request.topic, - requestId = request.requestId, - blockchain = networkId, - method = currentRequest.method, - ) - - if (signingResultData == null) { - walletConnectRepository.rejectRequest(requestData, WalletConnectError.SigningError) - } else { - walletConnectRepository.sendRequest( - requestData = requestData, - result = signingResultData, - ) - } - } - - /** - * Handles Wallet Connect deep links. - * If wallet connect is able to handle the deeplink, session is started with deeplink. - * Otherwise, deeplink is stored until wallet connect is ready to handle it. - * - * @param deeplink deeplink to handle - */ - fun addDeeplink(deeplink: String) { - if (isNewWc) return - val deeplinkRegex = Regex(WC_PARAM_REGEX) - val matched = deeplinkRegex.findAll(deeplink) - val sessionTopic = matched.firstOrNull { it.value.contains(WC_TOPIC_QUERY_NAME) }?.groupValues?.lastOrNull() - - val isAlreadyActiveSessionTopic = walletConnectRepository.currentSessions.any { session -> - session.topic == sessionTopic - } - - if (isAlreadyActiveSessionTopic && sessionTopic != null) { - Timber.i("WC already has an active session topic: $deeplink") - return - } - - if (isWalletConnectReadyForDeepLinks) { - val action = WalletConnectAction.OpenSession( - wcUri = deeplink, - source = WalletConnectAction.OpenSession.SourceType.DEEPLINK, - userWalletId = UserWalletId(userWalletId), - ) - store.dispatchOnMain(action) - } else { - deeplinkStack.push(deeplink) - } - } - - private fun getCardId(userWallet: UserWallet): String? { - return if (userWallet is UserWallet.Cold && userWallet.scanResponse.card.backupStatus?.isActive != true) { - userWallet.cardId - } else { // if wallet has backup, any card from wallet can be used to sign - null - } - } - - private suspend fun getAccountsForWc(userWallet: UserWallet, networks: List): List { - val walletManagers = networks.mapNotNull { - val blockchain = it.toBlockchain() - - walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWallet.walletId, - blockchain = blockchain, - derivationPath = it.derivationPath.value, - ) - } - return walletManagers.flatMap { - val wallet = it.wallet - val chainIds = blockchainHelper.networkIdToChainIdOrNull(wallet.blockchain.toNetworkId()) - blockchainHelper.chainIdsToAccounts( - walletAddress = wallet.address, - chainIds = chainIds, - derivationPath = wallet.publicKey.derivationPath?.rawPath, - ) - } - } - - private suspend fun prepareRequestData( - sessionRequest: WalletConnectEvents.SessionRequest, - ): Result { - return sessionRequestConverter.prepareRequest(sessionRequest, userWalletId) - } - - private companion object { - const val WC_TOPIC_QUERY_NAME = "sessionTopic" - const val WC_PARAM_REGEX = "([a-zA-Z\\d-]+)=([a-zA-Z\\d]+)" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt deleted file mode 100644 index 74241e4cf6..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.blockchain.common.Blockchain -import com.tangem.domain.walletconnect.model.legacy.Account - -interface WcBlockchainHelper { - fun chainIdToNetworkIdOrNull(chainId: String): String? - - fun chainIdToMissingNetworkNameOrNull(chainId: String): String? - - fun networkIdToChainIdOrNull(networkId: String): List - - fun getNamespaceFromFullChainIdOrNull(chainId: String): String? - - fun chainIdToFullNameOrNull(chainId: String): String? - - fun chainIdsToAccounts(walletAddress: String, chainIds: List, derivationPath: String?): List - - fun chainIdsToBlockchains(chainIds: List): List -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcJrpcMethods.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcJrpcMethods.kt deleted file mode 100644 index 3a79b205ed..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcJrpcMethods.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.squareup.moshi.* -import com.tangem.datasource.di.SdkMoshi -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceCancelOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTradeOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTransferOrder -import com.tangem.tap.domain.walletconnect2.domain.models.binance.WcBinanceTxConfirmParam -import com.tangem.tap.domain.walletconnect2.domain.models.solana.SolanaSignMessage -import com.tangem.tap.domain.walletconnect2.domain.models.solana.SolanaTransactionRequest -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -internal enum class WcJrpcMethods(val code: String) { - - ETH_SIGN("eth_sign"), - ETH_PERSONAL_SIGN("personal_sign"), - ETH_SIGN_TYPE_DATA("eth_signTypedData"), - ETH_SIGN_TYPE_DATA_V4("eth_signTypedData_v4"), - ETH_SIGN_TRANSACTION("eth_signTransaction"), - ETH_SEND_TRANSACTION("eth_sendTransaction"), - - BNB_SIGN("bnb_sign"), - BNB_TRANSACTION_CONFIRM("bnb_tx_confirmation"), - SIGN_TRANSACTION("trust_signTransaction"), - WALLET_ADD_ETHEREUM_CHAIN("wallet_addEthereumChain"), - - SOLANA_SIGN_TX("solana_signTransaction"), - SOLANA_SIGN_MESSAGE("solana_signMessage"), - SOLANA_SIGN_ALL_TX("solana_signAllTransactions"), - ; - - companion object { - fun fromCode(code: String): WcJrpcMethods? = values().firstOrNull { it.code == code } - } -} - -@JsonClass(generateAdapter = true) -data class WcSignTransaction( - @Json(name = "network") - val network: Int, - - @Json(name = "transaction") - val transaction: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class WcSignMessage( - @Json(name = "raw") - val raw: List, - - @Json(name = "type") - val type: WCSignType, -) : WcRequestData { - - @JsonClass(generateAdapter = false) - enum class WCSignType { - MESSAGE, PERSONAL_MESSAGE, TYPED_MESSAGE, SOLANA_MESSAGE, - } - - /** - * Raw parameters will always be the message and the address. Depending on the WCSignType, - * those parameters can be swapped as description below: - * - * - MESSAGE: `[address, data ]` - * - TYPED_MESSAGE: `[address, data]` - * - PERSONAL_MESSAGE: `[data, address]` - * - SOLANA_MESSAGE: `[publicKey (address), message]` - * - * reference: https://docs.walletconnect.org/json-rpc/ethereum#eth_signtypeddata - */ - val data - get() = when (type) { - WCSignType.PERSONAL_MESSAGE -> raw[0] - else -> raw[1] - } - - val address - get() = when (type) { - WCSignType.PERSONAL_MESSAGE -> raw[1] - else -> raw[0] - } -} - -@JsonClass(generateAdapter = true) -data class WcSignMessageData( - @Json(name = "address") - val address: String, - - @Json(name = "message") - val message: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class WcAddChain( - @Json(name = "chainId") - val chainId: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class WcEthereumTransaction( - @Json(name = "from") - val from: String, - - @Json(name = "to") - val to: String?, - - @Json(name = "nonce") - val nonce: String?, - - @Json(name = "gasPrice") - val gasPrice: String?, - - @Json(name = "maxFeePerGas") - val maxFeePerGas: String?, - - @Json(name = "maxPriorityFeePerGas") - val maxPriorityFeePerGas: String?, - - @Json(name = "gas") - val gas: String?, - - @Json(name = "gasLimit") - val gasLimit: String?, - - @Json(name = "value") - val value: String?, - - @Json(name = "data") - val data: String, -) : WcRequestData - -@JsonClass(generateAdapter = true) -data class SolanaTransactionsRequest( - @Json(name = "transactions") - val transactions: List, -) : WcRequestData - -interface WcRequestData - -data class WcCustomRequestData(val data: String) : WcRequestData - -sealed class WcRequest(open val data: WcRequestData) { - data class EthSign(override val data: WcSignMessage) : WcRequest(data) - data class EthSignTransaction(override val data: WcEthereumTransaction) : WcRequest(data) - data class EthSendTransaction(override val data: WcEthereumTransaction) : WcRequest(data) - data class BnbTrade(override val data: WcBinanceTradeOrder) : WcRequest(data) - data class BnbCancel(override val data: WcBinanceCancelOrder) : WcRequest(data) - data class BnbTransfer(override val data: WcBinanceTransferOrder) : WcRequest(data) - data class BnbTxConfirm(override val data: WcBinanceTxConfirmParam) : WcRequest(data) - data class SignTransaction(override val data: WcSignTransaction) : WcRequest(data) - data class AddChain(override val data: WcAddChain) : WcRequest(data) - data class CustomRequest(override val data: WcCustomRequestData) : WcRequest(data) - data class SolanaSignRequest(override val data: SolanaTransactionRequest) : WcRequest(data) - data class SolanaSignTransactions(override val data: SolanaTransactionsRequest) : WcRequest(data) -} - -@Singleton -internal class WcJrpcRequestsDeserializer @Inject constructor(@SdkMoshi private val moshi: Moshi) { - - @Suppress("ComplexMethod", "LongMethod") - fun deserialize(method: String, params: String): WcRequest { - val customRequest = WcRequest.CustomRequest(WcCustomRequestData(params)) - val wcMethod: WcJrpcMethods = WcJrpcMethods.fromCode(method) ?: return customRequest - - return when (wcMethod) { - WcJrpcMethods.ETH_SIGN_TRANSACTION -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcEthereumTransaction::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - WcRequest.EthSignTransaction(data = deserializedParams) - } - WcJrpcMethods.ETH_SEND_TRANSACTION -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcEthereumTransaction::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - WcRequest.EthSendTransaction(data = deserializedParams) - } - WcJrpcMethods.ETH_SIGN -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, String::class.java), - ).fromJsonOrNull(params) ?: return customRequest - val data = WcSignMessage( - raw = deserializedParams, - type = WcSignMessage.WCSignType.MESSAGE, - ) - WcRequest.EthSign(data = data) - } - WcJrpcMethods.ETH_PERSONAL_SIGN -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, String::class.java), - ).fromJsonOrNull(params) ?: return customRequest - val data = WcSignMessage( - raw = deserializedParams, - type = WcSignMessage.WCSignType.PERSONAL_MESSAGE, - ) - WcRequest.EthSign(data = data) - } - WcJrpcMethods.ETH_SIGN_TYPE_DATA, WcJrpcMethods.ETH_SIGN_TYPE_DATA_V4 -> { - val deserializedParams = listOf( - params.substring(params.indexOf("\"") + 1, params.indexOf("\"", startIndex = 2)), - params.substring(params.indexOfFirst { it == '{' }, params.indexOfLast { it == '}' } + 1), - ) - val data = WcSignMessage( - deserializedParams, - WcSignMessage.WCSignType.TYPED_MESSAGE, - ) - Timber.d("TypedData params: $deserializedParams") - WcRequest.EthSign(data) - } - WcJrpcMethods.BNB_SIGN -> { - return deserializeBnb(moshi, params) ?: customRequest - } - WcJrpcMethods.BNB_TRANSACTION_CONFIRM -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceTxConfirmParam::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - WcRequest.BnbTxConfirm(data = deserializedParams) - } - WcJrpcMethods.SIGN_TRANSACTION -> { - val deserializedParams = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcSignTransaction::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - - WcRequest.SignTransaction(data = deserializedParams) - } - WcJrpcMethods.WALLET_ADD_ETHEREUM_CHAIN -> { - val deserializedParams: WcAddChain = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcAddChain::class.java), - ).fromJsonFirstOrNull(params) ?: return customRequest - - WcRequest.AddChain(data = deserializedParams) - } - WcJrpcMethods.SOLANA_SIGN_TX -> { - val tx = moshi.adapter(SolanaTransactionRequest::class.java) - .fromJsonOrNull(params) - ?: return customRequest - - WcRequest.SolanaSignRequest(data = tx) - } - WcJrpcMethods.SOLANA_SIGN_MESSAGE -> { - val signMessage = moshi.adapter(SolanaSignMessage::class.java) - .fromJsonOrNull(params) - ?: return customRequest - val data = WcSignMessage( - raw = listOf(signMessage.publicKey, signMessage.message), - type = WcSignMessage.WCSignType.SOLANA_MESSAGE, - ) - - WcRequest.EthSign(data = data) - } - WcJrpcMethods.SOLANA_SIGN_ALL_TX -> { - val transactionsToSign = moshi.adapter(SolanaTransactionsRequest::class.java) - .fromJsonOrNull(params) ?: return customRequest - WcRequest.SolanaSignTransactions(transactionsToSign) - } - } - } - - private fun deserializeBnb(moshi: Moshi, params: String): WcRequest? { - val cancelOrder = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceCancelOrder::class.java), - ).fromJsonFirstOrNull(params) - if (cancelOrder != null) return WcRequest.BnbCancel(cancelOrder) - - val tradeOrder = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceTradeOrder::class.java), - ).fromJsonFirstOrNull(params) - if (tradeOrder != null) return WcRequest.BnbTrade(tradeOrder) - - val transferOrder = moshi.adapter>( - Types.newParameterizedType(List::class.java, WcBinanceTransferOrder::class.java), - ).fromJsonFirstOrNull(params) - if (transferOrder != null) return WcRequest.BnbTransfer(transferOrder) - - return null - } - - private fun JsonAdapter.fromJsonOrNull(data: String): T? { - return try { - this.fromJson(data) - } catch (e: Exception) { - Timber.e(e.message) - null - } - } - - private fun JsonAdapter>.fromJsonFirstOrNull(data: String): T? { - return try { - this.fromJson(data)?.firstOrNull() - } catch (e: Exception) { - Timber.e(e.message) - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcPreparedRequest.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcPreparedRequest.kt deleted file mode 100644 index ba9f21b2d6..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcPreparedRequest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.tap.domain.walletconnect2.domain.models.BnbData -import com.tangem.tap.features.details.redux.walletconnect.WcPersonalSignData -import com.tangem.tap.features.details.redux.walletconnect.WcTransactionData - -sealed class WcPreparedRequest( - open val preparedRequestData: Any, - val topic: String, - val requestId: Long, - val derivationPath: String?, -) { - class EthSign( - override val preparedRequestData: WcPersonalSignData, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class EthTransaction( - override val preparedRequestData: WcTransactionData, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class BnbTransaction( - override val preparedRequestData: BnbData, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class SolanaSignTransaction( - override val preparedRequestData: GenericTransactionData.SingleHash, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) - - class SolanaSignMultipleTransactions( - override val preparedRequestData: GenericTransactionData.MultipleHashes, - topic: String, - requestId: Long, - derivationPath: String?, - ) : WcPreparedRequest(preparedRequestData, topic, requestId, derivationPath) -} - -sealed interface GenericTransactionData { - val dAppName: String - val type: TransactionType - - data class SingleHash( - val hashToSign: ByteArray, - override val dAppName: String, - override val type: TransactionType, - ) : GenericTransactionData - - data class MultipleHashes( - val hashesToSign: List, - override val dAppName: String, - override val type: TransactionType, - ) : GenericTransactionData -} - -enum class TransactionType { - SOLANA_TX, -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt deleted file mode 100644 index 90de1f8b12..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository -import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper -import com.tangem.tap.domain.walletconnect2.domain.models.BnbData -import com.tangem.tap.domain.walletconnect2.domain.models.EthTransactionData -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType -import okio.ByteString.Companion.decodeBase64 - -internal class WcSessionRequestConverter( - private val blockchainHelper: WcBlockchainHelper, - private val sessionsRepository: WalletConnectSessionsRepository, - private val sdkHelper: WalletConnectSdkHelper, -) { - - @Suppress("LongMethod") - suspend fun prepareRequest( - sessionRequest: WalletConnectEvents.SessionRequest, - userWalletId: String, - ): Result = runCatching { - val networkId = requireNotNull(blockchainHelper.chainIdToNetworkIdOrNull(sessionRequest.chainId.orEmpty())) { - "Failed to get network ID for chain ID: ${sessionRequest.chainId}" - } - val derivationPath = getDerivationPath( - sessionsRepository = sessionsRepository, - sessionRequest = sessionRequest, - userWalletId = userWalletId, - walletAddress = getWalletAddress(sessionRequest.request), - ) - - when (val request = sessionRequest.request) { - is WcRequest.EthSendTransaction -> { - val data = sdkHelper.prepareTransactionData( - EthTransactionData( - transaction = request.data, - networkId = networkId, - rawDerivationPath = derivationPath, - id = sessionRequest.id, - topic = sessionRequest.topic, - type = WcEthTransactionType.EthSendTransaction, - metaName = sessionRequest.metaName, - metaUrl = sessionRequest.metaUrl, - ), - ) - - WcPreparedRequest.EthTransaction( - preparedRequestData = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.EthSignTransaction -> { - val data = sdkHelper.prepareTransactionData( - EthTransactionData( - transaction = request.data, - networkId = networkId, - rawDerivationPath = derivationPath, - id = sessionRequest.id, - topic = sessionRequest.topic, - type = WcEthTransactionType.EthSignTransaction, - metaName = sessionRequest.metaName, - metaUrl = sessionRequest.metaUrl, - ), - ) - - WcPreparedRequest.EthTransaction( - preparedRequestData = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.EthSign -> { - val data = sdkHelper.prepareDataForPersonalSign( - request.data, - sessionRequest.topic, - sessionRequest.metaName, - sessionRequest.id, - ) - WcPreparedRequest.EthSign( - preparedRequestData = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.BnbTrade -> { - val data = sdkHelper.prepareBnbTradeOrder(request.data) - WcPreparedRequest.BnbTransaction( - BnbData( - data = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - dAppName = sessionRequest.metaName, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.BnbTransfer -> { - val data = sdkHelper.prepareBnbTransferOrder(request.data) - WcPreparedRequest.BnbTransaction( - BnbData( - data = data, - topic = sessionRequest.topic, - requestId = sessionRequest.id, - dAppName = sessionRequest.metaName, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.SolanaSignRequest -> { - val transaction = request.data.transaction - - WcPreparedRequest.SolanaSignTransaction( - preparedRequestData = GenericTransactionData.SingleHash( - hashToSign = transaction.prepareSolanaTransaction(), - dAppName = sessionRequest.metaName, - type = TransactionType.SOLANA_TX, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - is WcRequest.SolanaSignTransactions -> { - val transactions = request.data.transactions - WcPreparedRequest.SolanaSignMultipleTransactions( - preparedRequestData = GenericTransactionData.MultipleHashes( - hashesToSign = transactions.map { it.prepareSolanaTransaction() }, // - dAppName = sessionRequest.metaName, - type = TransactionType.SOLANA_TX, - ), - topic = sessionRequest.topic, - requestId = sessionRequest.id, - derivationPath = derivationPath, - ) - } - else -> throw WalletConnectError.UnsupportedMethod - } - } - - /** - * Input transaction in Base64 string - */ - private fun String.prepareSolanaTransaction(): ByteArray { - val transaction = this.decodeBase64()?.toByteArray() ?: ByteArray(0) - return SolanaTransactionHelper.removeSignaturesPlaceholders(transaction) - } - - private fun getWalletAddress(request: WcRequest): String? { - return when (request) { - is WcRequest.BnbTrade -> request.data.accountNumber - is WcRequest.BnbTransfer -> request.data.accountNumber - is WcRequest.EthSendTransaction -> request.data.from - is WcRequest.EthSignTransaction -> request.data.from - is WcRequest.EthSign -> request.data.address - is WcRequest.SolanaSignRequest -> request.data.feePayer - else -> null - } - } - - private suspend fun getDerivationPath( - sessionsRepository: WalletConnectSessionsRepository, - sessionRequest: WalletConnectEvents.SessionRequest, - userWalletId: String, - walletAddress: String?, - ): String? { - return sessionsRepository.loadSessions(userWalletId) - .firstOrNull { it.topic == sessionRequest.topic } - ?.accounts?.firstOrNull { - // if walletAddress == null take first account - it.chainId == sessionRequest.chainId && - (walletAddress == null || it.walletAddress.lowercase() == walletAddress.lowercase()) - }?.derivationPath - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/BnbData.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/BnbData.kt deleted file mode 100644 index 194ca5d01d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/BnbData.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData - -data class BnbData( - val data: BinanceMessageData, - val topic: String, - val requestId: Long, - val dAppName: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/EthTransactionData.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/EthTransactionData.kt deleted file mode 100644 index 2138d6bd63..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/EthTransactionData.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -import com.tangem.tap.domain.walletconnect2.domain.WcEthereumTransaction -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType - -data class EthTransactionData( - val transaction: WcEthereumTransaction, - val networkId: String, - val rawDerivationPath: String?, - val id: Long, - val topic: String, - val type: WcEthTransactionType, - val metaName: String, - val metaUrl: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/NetworkNamespace.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/NetworkNamespace.kt deleted file mode 100644 index a431210e42..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/NetworkNamespace.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -@JvmInline -value class NetworkNamespace(val value: String) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/RequestData.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/RequestData.kt deleted file mode 100644 index 38cd19ca97..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/RequestData.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -data class RequestData( - val topic: String, - val requestId: Long, - val method: String, - val blockchain: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectError.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectError.kt deleted file mode 100644 index 40d82cdb51..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectError.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -sealed class WalletConnectError(val error: String) : Exception() { - - data object UnsupportedDApp : WalletConnectError("UnsupportedDApp") - - data class ApprovalErrorMissingNetworks( - val missingChains: List, - ) : WalletConnectError("ApprovalErrorMissingNetworks") - - data class ApprovalErrorAddNetwork( - val networks: List, - ) : WalletConnectError("ApprovalErrorAddNetwork") - - data class ApprovalErrorUnsupportedNetwork( - val unsupportedNetworks: List, - ) : WalletConnectError("ApprovalErrorUnsupportedNetwork") - - data class ExternalApprovalError( - override val message: String?, - ) : WalletConnectError("ExternalApprovalError") - - data class UnknownError( - override val message: String, - ) : WalletConnectError(message) - - data object WrongUserWallet : WalletConnectError("WrongUserWallet") - data object UnsupportedMethod : WalletConnectError("UnsupportedMethod") - data object SigningError : WalletConnectError("SigningError") - data object ValidationError : WalletConnectError("ValidationError") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt deleted file mode 100644 index 1298ede44d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -import com.tangem.domain.walletconnect.model.legacy.Account -import com.tangem.tap.domain.walletconnect2.domain.WcRequest -import java.net.URI - -sealed interface WalletConnectEvents { - data class SessionProposal( - val name: String, - val description: String, - val url: String, - val icons: List, - val requiredChainIds: List, - val optionalChainIds: List, - ) : WalletConnectEvents - - data class SessionApprovalError(val error: WalletConnectError) : WalletConnectEvents - data class SessionApprovalSuccess(val topic: String, val accounts: List) : WalletConnectEvents - data class SessionDeleted(val topic: String) : WalletConnectEvents - - data class SessionRequest( - val request: WcRequest, - val chainId: String?, - val topic: String, - val id: Long, - val metaName: String, - val metaUrl: String, - val method: String, - ) : WalletConnectEvents - - data class PairConnectError(val error: Throwable) : WalletConnectEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectSession.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectSession.kt deleted file mode 100644 index fb535bcc66..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectSession.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models - -data class WalletConnectSession( - val topic: String, - val icon: String?, - val name: String?, - val url: String?, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceCancelOrder.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceCancelOrder.kt deleted file mode 100644 index 112c92e7a6..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceCancelOrder.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@Suppress("LongParameterList") -@JsonClass(generateAdapter = true) -data class WcBinanceCancelOrder( - @Json(name = "account_number") - val accountNumber: String, - @Json(name = "chain_id") - val chainId: String, - @Json(name = "data") - val data: String?, - @Json(name = "memo") - val memo: String?, - @Json(name = "sequence") - val sequence: String, - @Json(name = "source") - val source: String, - @Json(name = "msgs") - val msgs: List, -) : WcRequestData { - - @JsonClass(generateAdapter = true) - data class Message( - @Json(name = "refid") - val refid: String, - @Json(name = "sender") - val sender: String, - @Json(name = "symbol") - val symbol: String, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTradeOrder.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTradeOrder.kt deleted file mode 100644 index bdeaba5b27..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTradeOrder.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.github.salomonbrys.kotson.jsonSerializer -import com.google.gson.JsonObject -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@Suppress("LongParameterList") -@JsonClass(generateAdapter = true) -data class WcBinanceTradeOrder( - @Json(name = "account_number") - val accountNumber: String, - @Json(name = "chain_id") - val chainId: String, - @Json(name = "data") - val data: String?, - @Json(name = "memo") - val memo: String?, - @Json(name = "sequence") - val sequence: String, - @Json(name = "source") - val source: String, - @Json(name = "msgs") - val msgs: List, -) : WcRequestData { - - @JsonClass(generateAdapter = false) - enum class MessageKey(val key: String) { - ID("id"), - ORDER_TYPE("ordertype"), - PRICE("price"), - QUANTITY("quantity"), - SENDER("sender"), - SIDE("side"), - SYMBOL("symbol"), - TIME_INFORCE("timeinforce"), - } - - @JsonClass(generateAdapter = true) - data class Message( - @Json(name = "id") - val id: String, - @Json(name = "orderType") - val orderType: Int, - @Json(name = "price") - val price: Long, - @Json(name = "quantity") - val quantity: Long, - @Json(name = "sender") - val sender: String, - @Json(name = "side") - val side: Int, - @Json(name = "symbol") - val symbol: String, - @Json(name = "timeInforce") - val timeInforce: Int, - ) -} - -val tradeOrderSerializer = jsonSerializer { - val jsonObject = JsonObject() - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.ID.key, it.src.id) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.ORDER_TYPE.key, it.src.orderType) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.PRICE.key, it.src.price) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.QUANTITY.key, it.src.quantity) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.SENDER.key, it.src.sender) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.SIDE.key, it.src.side) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.SYMBOL.key, it.src.symbol) - jsonObject.addProperty(WcBinanceTradeOrder.MessageKey.TIME_INFORCE.key, it.src.timeInforce) - - jsonObject -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTransferOrder.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTransferOrder.kt deleted file mode 100644 index 99199c17dc..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTransferOrder.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@Suppress("LongParameterList") -@JsonClass(generateAdapter = true) -data class WcBinanceTransferOrder( - @Json(name = "account_number") - val accountNumber: String, - @Json(name = "chain_id") - val chainId: String, - @Json(name = "data") - val data: String?, - @Json(name = "memo") - val memo: String?, - @Json(name = "sequence") - val sequence: String, - @Json(name = "source") - val source: String, - @Json(name = "msgs") - val msgs: List, -) : WcRequestData { - - @JsonClass(generateAdapter = true) - data class Message( - @Json(name = "inputs") - val inputs: List, - @Json(name = "outputs") - val outputs: List, - ) { - - @JsonClass(generateAdapter = true) - data class Item( - @Json(name = "address") - val address: String, - @Json(name = "coins") - val coins: List, - ) { - - @JsonClass(generateAdapter = true) - data class Coin( - @Json(name = "amount") - val amount: Long, - @Json(name = "denom") - val denom: String, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTxConfirmParam.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTxConfirmParam.kt deleted file mode 100644 index 24ada4ca98..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/binance/WcBinanceTxConfirmParam.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.binance - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@JsonClass(generateAdapter = true) -data class WcBinanceTxConfirmParam( - @Json(name = "ok") - val ok: Boolean, - @Json(name = "errorMsg") - val errorMsg: String?, -) : WcRequestData \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaSignMessage.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaSignMessage.kt deleted file mode 100644 index a521aa55e9..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaSignMessage.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.solana - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -internal data class SolanaSignMessage( - @Json(name = "pubkey") - val publicKey: String, - - @Json(name = "message") - val message: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaTransactionRequest.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaTransactionRequest.kt deleted file mode 100644 index bed9fd3897..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/solana/SolanaTransactionRequest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.domain.models.solana - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import com.tangem.tap.domain.walletconnect2.domain.WcRequestData - -@JsonClass(generateAdapter = true) -data class SolanaTransactionRequest( - @Json(name = "feePayer") - val feePayer: String?, - - @Json(name = "transaction") - val transaction: String, -) : WcRequestData \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/DefaultWalletConnectFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/DefaultWalletConnectFeatureToggles.kt deleted file mode 100644 index 03cb9b878d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/DefaultWalletConnectFeatureToggles.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.tap.domain.walletconnect2.toggles - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles - -internal class DefaultWalletConnectFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : WalletConnectFeatureToggles { - - override val isRedesignedWalletConnectEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled("WALLET_CONNECT_REDESIGN_ENABLED") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt index 0ba699bde1..e4070d4b60 100644 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt @@ -1,44 +1,22 @@ package com.tangem.tap.features.demo -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.wallet.R -import org.rekotlin.Action object DemoHelper { val config = DemoConfig() - private val disabledActionFeatures = listOf( - WalletConnectAction.StartWalletConnect::class.java, - ) - fun isDemoCard(scanResponse: ScanResponse): Boolean = isDemoCardId(scanResponse.card.cardId) fun isTestDemoCard(scanResponse: ScanResponse): Boolean = config.isTestDemoCardId(scanResponse.card.cardId) fun isDemoCardId(cardId: String): Boolean = config.isDemoCardId(cardId) - fun tryHandle(appState: () -> AppState?, action: Action): Boolean { + fun tryHandle(appState: () -> AppState?): Boolean { val scanResponse = getScanResponse(appState) ?: return false if (!scanResponse.isDemoCard()) return false - disabledActionFeatures.firstOrNull { it == action::class.java }?.let { - val uiMessageSender = store.inject(DaggerGraphState::uiMessageSender) - uiMessageSender.send( - DialogMessage( - message = resourceReference(R.string.alert_demo_feature_disabled), - ), - ) - return true - } - return false } diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt index 9281cbc507..2ebe0494ab 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt @@ -39,7 +39,7 @@ class DetailsMiddleware { val detailsMiddleware: Middleware = { _, stateProvider -> { next -> { action -> - if (!DemoHelper.tryHandle(stateProvider, action)) { + if (!DemoHelper.tryHandle(stateProvider)) { val detailsState = stateProvider()?.detailsState if (detailsState != null) { handleAction(detailsState, action) diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt deleted file mode 100644 index bc90c602d0..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import org.rekotlin.Action - -sealed class WalletConnectAction : Action { - data class HandleDeepLink(val wcUri: String?) : WalletConnectAction() - - data object StartWalletConnect : WalletConnectAction() - - data class OpenSession( - val wcUri: String, - val source: SourceType, - val userWalletId: UserWalletId, - ) : WalletConnectAction() { - enum class SourceType { QR, DEEPLINK, CLIPBOARD, ETC } - } - - data class DisconnectSession(val topic: String) : WalletConnectAction() - - data class RejectRequest(val topic: String, val id: Long) : WalletConnectAction() - - //region WalletConnect 2.0 - data class ApproveProposal(val proposal: WalletConnectEvents.SessionProposal) : WalletConnectAction() - data object RejectProposal : WalletConnectAction() - - data object SessionEstablished : WalletConnectAction() - data class SessionRejected(val error: WalletConnectError) : WalletConnectAction() - data class SessionListUpdated(val sessions: List) : WalletConnectAction() - - data class ShowSessionRequest(val sessionRequest: WcPreparedRequest) : WalletConnectAction() - - data object RejectUnsupportedRequest : WalletConnectAction() - - data class PerformRequestedAction(val sessionRequest: WcPreparedRequest) : WalletConnectAction() - - data class PairConnectErrorAction(val throwable: Throwable) : WalletConnectAction() - - data object UnsupportedDappRequest : WalletConnectAction() - //endregion WalletConnect 2.0 -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt deleted file mode 100644 index 499e1861b0..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt +++ /dev/null @@ -1,202 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.common.routing.AppRoute -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppDialog -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError -import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import com.tangem.wallet.R -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware -import timber.log.Timber - -@Suppress("LargeClass") -class WalletConnectMiddleware { - private val walletConnectInteractor: WalletConnectInteractor - get() = store.inject(DaggerGraphState::walletConnectInteractor) - private val walletConnectRepository: LegacyWalletConnectRepository - get() = store.inject(DaggerGraphState::walletConnectRepository) - - val walletConnectMiddleware: Middleware = { dispatch, state -> - { next -> - { action -> - handle(state, action) - next(action) - } - } - } - - @Suppress("ComplexMethod", "LongMethod") - private fun handle(state: () -> AppState?, action: Action) { - if (DemoHelper.tryHandle(state, action)) return - - when (action) { - is WalletConnectAction.HandleDeepLink -> { - val wsUrl = action.wcUri - Timber.i("WC deeplink: $wsUrl") - if (!wsUrl.isNullOrBlank()) { - Timber.i("WC deeplink added to stack: $wsUrl") - walletConnectInteractor.addDeeplink(wsUrl) - } - } - is WalletConnectAction.DisconnectSession -> { - walletConnectInteractor.disconnectSession(action.topic) - } - is WalletConnectAction.StartWalletConnect -> { - store.dispatchNavigationAction { - push(AppRoute.QrScanning(AppRoute.QrScanning.Source.WalletConnect)) - } - } - is WalletConnectAction.OpenSession -> { - val index = action.wcUri.indexOf("@") - when (action.wcUri[index + 1]) { - '2' -> walletConnectRepository.pair( - uri = action.wcUri, - source = action.source, - userWalletId = action.userWalletId, - ) - '1' -> { - store.dispatchOnMain(WalletConnectAction.UnsupportedDappRequest) - store.dispatchOnMain( - GlobalAction.ShowDialog(WalletConnectDialog.UnsupportedWcVersion), - ) - } - } - } - is WalletConnectAction.RejectRequest -> { - walletConnectInteractor.cancelRequest(action.topic, action.id) - } - is WalletConnectAction.ApproveProposal -> { - scope.launch { - val proposalChainIds = action.proposal.requiredChainIds + action.proposal.optionalChainIds - val proposalBlockchains = - walletConnectInteractor.blockchainHelper.chainIdsToBlockchains(proposalChainIds) - val accounts = getWalletManagers() - .filter { walletManager -> proposalBlockchains.contains(walletManager.wallet.blockchain) } - .flatMap { - val wallet = it.wallet - val chainIds = walletConnectInteractor.blockchainHelper.networkIdToChainIdOrNull( - wallet.blockchain.toNetworkId(), - ) - walletConnectInteractor.blockchainHelper.chainIdsToAccounts( - walletAddress = wallet.address, - chainIds = chainIds, - derivationPath = wallet.publicKey.derivationPath?.rawPath, - ) - } - walletConnectInteractor.approveSessionProposal(accounts) - } - } - is WalletConnectAction.RejectProposal -> { - walletConnectInteractor.rejectSessionProposal() - } - is WalletConnectAction.SessionEstablished -> { - } - is WalletConnectAction.SessionRejected -> { - when (action.error) { - is WalletConnectError.ApprovalErrorAddNetwork -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.AddNetwork(action.error.networks), - ), - ) - } - is WalletConnectError.ApprovalErrorUnsupportedNetwork -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.UnsupportedNetwork(action.error.unsupportedNetworks), - ), - ) - } - is WalletConnectError.UnsupportedDApp -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.UnsupportedDapp, - ), - ) - } - is WalletConnectError.UnknownError -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - AppDialog.SimpleOkDialogRes( - headerId = R.string.wallet_connect_title, - messageId = R.string.wallet_connect_error_with_framework_message, - args = listOf(action.error.message), - ), - ), - ) - } - is WalletConnectError.ExternalApprovalError -> { - Timber.e(action.error, "ExternalApprovalError ${action.error.message}") - // do not show dialog on this event - // val message = action.error.message - // if (!message.isNullOrEmpty()) { - // store.dispatchOnMain( - // GlobalAction.ShowDialog( - // AppDialog.SimpleOkWarningDialog( - // message = message, - // ), - // ), - // ) - // } - } - else -> Unit - } - } - - is WalletConnectAction.ShowSessionRequest -> { - val dialog: WalletConnectDialog = when (val request = action.sessionRequest) { - is WcPreparedRequest.BnbTransaction -> WalletConnectDialog.BnbTransactionDialog(request) - is WcPreparedRequest.EthTransaction -> WalletConnectDialog.RequestTransaction(request) - is WcPreparedRequest.EthSign -> WalletConnectDialog.PersonalSign(request) - is WcPreparedRequest.SolanaSignTransaction -> - WalletConnectDialog.SignTransactionDialog(request) - is WcPreparedRequest.SolanaSignMultipleTransactions -> - WalletConnectDialog.SignTransactionsDialog(request) - } - store.dispatch(GlobalAction.ShowDialog(dialog)) - } - is WalletConnectAction.PerformRequestedAction -> { - scope.launch { walletConnectInteractor.continueWithRequest(action.sessionRequest) } - } - is WalletConnectAction.RejectUnsupportedRequest -> { - store.dispatchOnMain(GlobalAction.ShowDialog(WalletConnectDialog.UnsupportedNetwork())) - } - is WalletConnectAction.PairConnectErrorAction -> { - store.dispatch(GlobalAction.ShowDialog(WalletConnectDialog.PairConnectErrorDialog(action.throwable))) - } - } - } - - private suspend fun getWalletManagers(): List { - val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) - val userWallet = getSelectedWallet() ?: return emptyList() - return walletManagerFacade.getStoredWalletManagers(userWallet.walletId) - } - - private suspend fun getSelectedWallet(): UserWallet? { - val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - val hotWalletFeatureToggles = store.inject(DaggerGraphState::hotWalletFeatureToggles) - - return if (hotWalletFeatureToggles.isHotWalletEnabled) { - userWalletsListRepository.selectedUserWalletSync() - } else { - userWalletsListManager.selectedUserWalletSync - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectReducer.kt deleted file mode 100644 index 1cf6c4da24..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectReducer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import org.rekotlin.Action - -object WalletConnectReducer { - fun reduce(action: Action, state: WalletConnectState): WalletConnectState { - if (action !is WalletConnectAction) return state - - return when (action) { - is WalletConnectAction.OpenSession -> { - state.copy(loading = true) - } - is WalletConnectAction.ApproveProposal -> state.copy(loading = true) - is WalletConnectAction.RejectProposal, - is WalletConnectAction.SessionEstablished, - is WalletConnectAction.SessionRejected, - is WalletConnectAction.PairConnectErrorAction, - is WalletConnectAction.UnsupportedDappRequest, - -> state.copy(loading = false) - is WalletConnectAction.SessionListUpdated -> state.copy( - wc2Sessions = action.sessions, - ) - else -> state - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt deleted file mode 100644 index ac28ca0b95..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.tangem.tap.features.details.redux.walletconnect - -import com.squareup.moshi.JsonClass -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchain.common.derivation.DerivationStyle -import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.redux.StateDialog -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.domain.walletconnect2.domain.WcSignMessage -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen -import com.tangem.tap.features.details.ui.walletconnect.dialogs.PersonalSignDialogData -import com.tangem.tap.features.details.ui.walletconnect.dialogs.TransactionRequestDialogData - -data class WalletConnectState( - val loading: Boolean = false, - val wc2Sessions: List = listOf(), - val newSessionData: NewWcSessionData? = null, -) - -data class NewWcSessionData( - val session: WalletConnectSession, - val scanResponse: ScanResponse, - val blockchain: Blockchain?, -) - -data class WalletConnectSession( - val peerId: String, - val remotePeerId: String?, - val wallet: WalletForSession, -) { - fun getAddress(): String? { - val key = wallet.derivedPublicKey ?: wallet.walletPublicKey ?: return null - return wallet.blockchain?.makeAddresses(key)?.first()?.value - } -} - -@JsonClass(generateAdapter = true) -data class WalletForSession( - val walletPublicKey: ByteArray? = null, - val derivedPublicKey: ByteArray? = null, - val derivationPath: DerivationPath? = null, - val derivationStyle: DerivationStyle? = null, - val isTestNet: Boolean = false, - val blockchain: Blockchain? = if (isTestNet) Blockchain.EthereumTestnet else Blockchain.Ethereum, -) { - - fun getBlockchainForSession(): Blockchain { - return blockchain ?: if (isTestNet) Blockchain.EthereumTestnet else Blockchain.Ethereum - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as WalletForSession - - if (walletPublicKey != null) { - if (other.walletPublicKey == null) return false - if (!walletPublicKey.contentEquals(other.walletPublicKey)) return false - } else if (other.walletPublicKey != null) return false - if (derivedPublicKey != null) { - if (other.derivedPublicKey == null) return false - if (!derivedPublicKey.contentEquals(other.derivedPublicKey)) return false - } else if (other.derivedPublicKey != null) return false - if (derivationPath != other.derivationPath) return false - if (isTestNet != other.isTestNet) return false - return blockchain == other.blockchain - } - - override fun hashCode(): Int { - var result = walletPublicKey?.contentHashCode() ?: 0 - result = 31 * result + (derivedPublicKey?.contentHashCode() ?: 0) - result = 31 * result + (derivationPath?.hashCode() ?: 0) - result = 31 * result + isTestNet.hashCode() - result = 31 * result + (blockchain?.hashCode() ?: 0) - return result - } -} - -sealed class WalletConnectDialog : StateDialog { - data object UnsupportedWcVersion : WalletConnectDialog() - data object UnsupportedCard : WalletConnectDialog() - data class UnsupportedNetwork(val networks: List? = null) : WalletConnectDialog() - data object UnsupportedDapp : WalletConnectDialog() - data class AddNetwork(val networks: List) : WalletConnectDialog() - data object OpeningSessionRejected : WalletConnectDialog() - data object SessionTimeout : WalletConnectDialog() - data class ApproveWcSession( - val session: WalletConnectSession, - val networks: List, - ) : WalletConnectDialog() - - data class SessionProposalDialog( - val sessionProposal: WalletConnectEvents.SessionProposal, - val networks: String, - val onApprove: () -> Unit, - val onReject: () -> Unit, - ) : WalletConnectDialog() - - data class ChooseNetwork( - val session: WalletConnectSession, - val networks: List, - ) : WalletConnectDialog() - - data class RequestTransaction(val data: WcPreparedRequest.EthTransaction) : - WalletConnectDialog() - - data class PersonalSign(val data: WcPreparedRequest.EthSign) : WalletConnectDialog() - data class BnbTransactionDialog( - val data: WcPreparedRequest.BnbTransaction, - ) : WalletConnectDialog() - - data class SignTransactionDialog( - val data: WcPreparedRequest.SolanaSignTransaction, - ) : WalletConnectDialog() - - data class SignTransactionsDialog( - val data: WcPreparedRequest.SolanaSignMultipleTransactions, - ) : WalletConnectDialog() - - data class PairConnectErrorDialog(val error: Throwable) : WalletConnectDialog() -} - -data class WcTransactionData( - val type: WcEthTransactionType, - val transaction: TransactionData, - val topic: String, - val id: Long, - val walletManager: WalletManager, - val dialogData: TransactionRequestDialogData, -) - -enum class WcEthTransactionType { - EthSignTransaction, - EthSendTransaction, -} - -data class WcPersonalSignData( - val hash: ByteArray, - val topic: String, - val id: Long, - val dialogData: PersonalSignDialogData, - val type: WcSignMessage.WCSignType, -) - -sealed class BinanceMessageData( - val address: String, - val data: ByteArray, -) { - class Trade( - val tradeData: List, - address: String, - data: ByteArray, - ) : BinanceMessageData(address, data) - - class Transfer( - val outputAddress: String, - val amount: String, - address: String, - data: ByteArray, - ) : BinanceMessageData(address, data) -} - -data class TradeData( - val price: String, - val quantity: String, - val amount: String, - val symbol: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/DefaultWalletConnectComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/DefaultWalletConnectComponent.kt deleted file mode 100644 index 0974e0db1b..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/DefaultWalletConnectComponent.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import com.arkivanov.essenty.lifecycle.subscribe -import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.Analytics -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.analytics.events.WalletConnect -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent -import com.tangem.tap.store -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import org.rekotlin.StoreSubscriber - -@Suppress("UnusedPrivateMember") -internal class DefaultWalletConnectComponent @AssistedInject constructor( - @Assisted appComponentContext: AppComponentContext, - @Assisted params: WalletConnectComponent.Params, -) : WalletConnectComponent, AppComponentContext by appComponentContext, StoreSubscriber { - - private val model: WalletConnectModel = getOrCreateModel(params) - - private var screenState: MutableState = - mutableStateOf(model.updateState(store.state.walletConnectState)) - - init { - lifecycle.subscribe( - onCreate = { - Analytics.send(WalletConnect.ScreenOpened()) - }, - onStart = { - store.subscribe(this) { state -> - state.skipRepeats { oldState, newState -> - oldState.walletConnectState == newState.walletConnectState - }.select { it.walletConnectState } - } - }, - onStop = { - store.unsubscribe(this) - }, - ) - } - - override fun newState(state: WalletConnectState) { - screenState.value = model.updateState(state) - } - - @Composable - override fun Content(modifier: Modifier) { - WalletConnectScreen( - modifier = modifier, - state = screenState.value, - onBackClick = { - store.dispatchNavigationAction(AppRouter::pop) - }, - ) - } - - @AssistedFactory - interface Factory : WalletConnectComponent.Factory { - override fun create( - context: AppComponentContext, - params: WalletConnectComponent.Params, - ): DefaultWalletConnectComponent - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt deleted file mode 100644 index a231426ebb..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import androidx.compose.runtime.Stable -import arrow.core.getOrElse -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.domain.qrscanning.models.QrResultSource -import com.tangem.domain.qrscanning.models.SourceType -import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent -import com.tangem.tap.store -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -@Stable -@ModelScoped -internal class WalletConnectModel @Inject constructor( - override val dispatchers: CoroutineDispatcherProvider, - private val listenToQrScanningUseCase: ListenToQrScanningUseCase, - paramsContainer: ParamsContainer, -) : Model() { - - private val params = paramsContainer.require() - - init { - modelScope.launch { - listenToQrScanningUseCase.listen(SourceType.WALLET_CONNECT) - .getOrElse { emptyFlow() } - .map { result -> - val source = when (result.resultSource) { - QrResultSource.CLIPBOARD -> WalletConnectAction.OpenSession.SourceType.CLIPBOARD - QrResultSource.CAMERA, - QrResultSource.GALLERY, - -> WalletConnectAction.OpenSession.SourceType.QR - } - WalletConnectAction.OpenSession( - wcUri = result.qrCode, - source = source, - userWalletId = params.userWalletId, - ) - } - .collect { store.dispatch(it) } - } - } - - fun updateState(state: WalletConnectState): WalletConnectScreenState { - Timber.d("WC2 Sessions: ${state.wc2Sessions}") - val sessions = state.wc2Sessions - return WalletConnectScreenState( - sessions.toImmutableList(), - isLoading = state.loading, - onRemoveSession = { sessionUri -> onRemoveSession(sessionUri, sessions) }, - onAddSession = { - store.dispatch(WalletConnectAction.StartWalletConnect) - }, - ) - } - - private fun onRemoveSession(sessionUri: String, wc2sessions: List) { - wc2sessions.firstOrNull { it.sessionId == sessionUri }?.let { - store.dispatch(WalletConnectAction.DisconnectSession(sessionUri)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreen.kt deleted file mode 100644 index 252fc23b51..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreen.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.tangem.core.analytics.Analytics -import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold -import com.tangem.wallet.R -import kotlinx.collections.immutable.persistentListOf - -@Composable -internal fun WalletConnectScreen( - state: WalletConnectScreenState, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, -) { - SettingsScreensScaffold( - modifier = modifier, - content = { - if (state.sessions.isEmpty()) { - EmptyScreen(state) - } else { - WalletConnectSessions(state) - } - }, - fab = { - if (!state.isLoading) { - AddSessionFab( - onAddSession = { - Analytics.send(Settings.ButtonStartWalletConnectSession()) - state.onAddSession() - }, - ) - } - }, - titleRes = R.string.wallet_connect_title, - onBackClick = onBackClick, - ) -} - -@Composable -private fun AddSessionFab(onAddSession: () -> Unit, modifier: Modifier = Modifier) { - FloatingActionButton( - onClick = onAddSession, - containerColor = TangemTheme.colors.button.primary, - contentColor = TangemTheme.colors.icon.primary2, - shape = RoundedCornerShape(16.dp), - modifier = modifier, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_plus_24), - contentDescription = "", - ) - } -} - -@Composable -private fun EmptyScreen(state: WalletConnectScreenState) { - if (state.isLoading) { - TangemLinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = TangemTheme.colors.icon.accent, - ) - } - Column( - modifier = Modifier - .fillMaxSize() - .padding(bottom = 64.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(id = R.drawable.ic_wallet_connect_24), - contentDescription = "", - colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), - contentScale = ContentScale.FillWidth, - modifier = Modifier.width(width = 100.dp), - ) - Spacer(modifier = Modifier.size(24.dp)) - Text( - text = stringResourceSafe(id = R.string.wallet_connect_subtitle), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, - ) - } -} - -@Composable -private fun WalletConnectSessions(state: WalletConnectScreenState) { - if (state.isLoading) { - TangemLinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(2.dp), - color = TangemTheme.colors.icon.accent, - ) - } else { - Spacer(modifier = Modifier.height(2.dp)) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - items(state.sessions) { session -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = session.description, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier.weight(1f), - ) - IconButton( - onClick = { - Analytics.send(Settings.ButtonStopWalletConnectSession()) - state.onRemoveSession(session.sessionId) - }, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_cross_rounded_24), - contentDescription = "", - tint = TangemTheme.colors.icon.warning, - ) - } - } - } - } -} - -@Composable -@Preview -private fun WalletConnectScreenPreview() { - WalletConnectScreen( - state = WalletConnectScreenState( - sessions = persistentListOf( - WcSessionForScreen( - description = "session from some dApp", - sessionId = "", - ), - ), - isLoading = true, - ), - {}, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreenState.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreenState.kt deleted file mode 100644 index 0a3d429cd6..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectScreenState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect - -import kotlinx.collections.immutable.ImmutableList - -internal data class WalletConnectScreenState( - val sessions: ImmutableList, - val isLoading: Boolean = false, - val onRemoveSession: (String) -> Unit = {}, - val onAddSession: () -> Unit = {}, -) - -data class WcSessionForScreen( - val description: String, - val sessionId: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/api/WalletConnectComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/api/WalletConnectComponent.kt deleted file mode 100644 index 2c97e35722..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/api/WalletConnectComponent.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.api - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId - -interface WalletConnectComponent : ComposableContentComponent { - data class Params(val userWalletId: UserWalletId) - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/di/WalletConnectFeatureModule.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/di/WalletConnectFeatureModule.kt deleted file mode 100644 index 20a85d18be..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/di/WalletConnectFeatureModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.di - -import com.tangem.core.decompose.model.Model -import com.tangem.tap.features.details.ui.walletconnect.DefaultWalletConnectComponent -import com.tangem.tap.features.details.ui.walletconnect.WalletConnectModel -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.ClassKey -import dagger.multibindings.IntoMap - -@Module -@InstallIn(SingletonComponent::class) -internal interface WalletConnectFeatureModule { - - @Binds - fun bindFactory(impl: DefaultWalletConnectComponent.Factory): WalletConnectComponent.Factory - - @Binds - @IntoMap - @ClassKey(WalletConnectModel::class) - fun bindModel(model: WalletConnectModel): Model -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/BnbTransactionDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/BnbTransactionDialog.kt deleted file mode 100644 index aee88834e4..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/BnbTransactionDialog.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.BinanceMessageData -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -object BnbTransactionDialog { - fun create(preparedData: WcPreparedRequest.BnbTransaction, context: Context): AlertDialog { - val data = preparedData.preparedRequestData.data - val message = when (data) { - is BinanceMessageData.Trade -> data.tradeData.map { - context.getString( - R.string.wallet_connect_bnb_trade_order_message, - it.symbol, - it.price, - it.quantity, - it.amount, - ) - }.joinToString(separator = "\n\n") - is BinanceMessageData.Transfer -> context.getString( - R.string.wallet_connect_bnb_transaction_message, - data.address, - data.outputAddress, - data.amount, - ) - } - - val fullMessage = context.getString( - R.string.wallet_connect_bnb_sign_message, - preparedData.preparedRequestData.dAppName, - message, - ) - val positiveButtonTitle = context.getText(R.string.common_sign) - - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(fullMessage) - setPositiveButton(positiveButtonTitle) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(preparedData.topic, preparedData.requestId)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/PersonalSignDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/PersonalSignDialog.kt deleted file mode 100644 index 4403b6d4a6..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/PersonalSignDialog.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -object PersonalSignDialog { - fun create(preparedData: WcPreparedRequest.EthSign, context: Context): AlertDialog { - val data = preparedData.preparedRequestData.dialogData - val message = context.getString(R.string.wallet_connect_alert_sign_message, data.message) - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_sign)) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(data.topic, data.id)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} - -data class PersonalSignDialogData( - val dAppName: String, - val message: String, - val topic: String, - val id: Long, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SessionProposalDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SessionProposalDialog.kt deleted file mode 100644 index 1dd8aff5a7..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SessionProposalDialog.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents -import com.tangem.tap.store -import com.tangem.wallet.R - -object SessionProposalDialog { - fun create( - sessionProposal: WalletConnectEvents.SessionProposal, - networks: String, - context: Context, - onApprove: () -> Unit, - onReject: () -> Unit, - ): AlertDialog { - val message = context.getString( - R.string.wallet_connect_request_session_start, - sessionProposal.name, - networks, - sessionProposal.url, - ) - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_start)) { _, _ -> - store.dispatch(GlobalAction.HideDialog) - onApprove() - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(GlobalAction.HideDialog) - onReject() - } - setOnCancelListener { - store.dispatch(GlobalAction.HideDialog) - onReject() - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionDialog.kt deleted file mode 100644 index e459b6065c..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionDialog.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -internal object SignTransactionDialog { - - fun create(preparedData: WcPreparedRequest.SolanaSignTransaction, context: Context): AlertDialog { - val signMessage = context.getString(R.string.wallet_connect_alert_sign_message, "") - val message = "${preparedData.preparedRequestData.dAppName}\n$signMessage" - return AlertDialog.Builder(context).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_sign)) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(preparedData.topic, preparedData.requestId)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionsDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionsDialog.kt deleted file mode 100644 index 579940d0ce..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/SignTransactionsDialog.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -internal object SignTransactionsDialog { - - fun create(preparedData: WcPreparedRequest.SolanaSignMultipleTransactions, context: Context): AlertDialog { - val signMessage = context.getString(R.string.wallet_connect_alert_sign_message, "") - val message = "${preparedData.preparedRequestData.dAppName}\n$signMessage" - return AlertDialog.Builder(context).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(context.getText(R.string.common_sign)) { _, _ -> - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(preparedData.topic, preparedData.requestId)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/TransactionDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/TransactionDialog.kt deleted file mode 100644 index 4fd49641fb..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/TransactionDialog.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType -import com.tangem.tap.store -import com.tangem.wallet.R - -object TransactionDialog { - fun create(preparedData: WcPreparedRequest.EthTransaction, context: Context): AlertDialog { - val data = preparedData.preparedRequestData.dialogData - val message = context.getString( - R.string.wallet_connect_create_tx_message, - data.dAppName, - data.dAppUrl, - data.amount, - data.feeAmount, - data.totalAmount, - data.balance, - ) - - val positiveButtonTitle = when (data.type) { - WcEthTransactionType.EthSignTransaction -> context.getText(R.string.common_sign) - WcEthTransactionType.EthSendTransaction -> context.getText(R.string.common_sign_and_send) - } - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.wallet_connect_title)) - setMessage(message) - setPositiveButton(positiveButtonTitle) { _, _ -> - if (data.isEnoughFundsToSend) { - store.dispatch(WalletConnectAction.PerformRequestedAction(preparedData)) - } else { - store.dispatch(WalletConnectAction.RejectRequest(data.topic, data.id)) - } - } - setNegativeButton(context.getText(R.string.common_reject)) { _, _ -> - store.dispatch(WalletConnectAction.RejectRequest(data.topic, data.id)) - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} - -data class TransactionRequestDialogData( - val dAppName: String, - val dAppUrl: String, - val amount: String, - val feeAmount: String, - val totalAmount: String, - val balance: String, - val isEnoughFundsToSend: Boolean, - val topic: String, - val id: Long, - val type: WcEthTransactionType, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/WalletConnectLinkIntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/WalletConnectLinkIntentHandler.kt deleted file mode 100644 index e715290a1e..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/WalletConnectLinkIntentHandler.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tangem.tap.features.intentHandler.handlers - -import android.content.Intent -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.removePrefixOrNull -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.features.intentHandler.AffectsNavigation -import com.tangem.tap.features.intentHandler.IntentHandler -import com.tangem.tap.store -import timber.log.Timber -import java.net.URLDecoder - -/** -[REDACTED_AUTHOR] - */ -class WalletConnectLinkIntentHandler : IntentHandler, AffectsNavigation { - - override fun handleIntent(intent: Intent?, isFromForeground: Boolean): Boolean { - val intentData = intent?.data ?: return false - val scheme = intent.scheme ?: return false - - val wcUri = when (scheme) { - WC_SCHEME -> intentData.toString() - TANGEM_SCHEME -> intentData.toString().removePrefixOrNull(TANGEM_WC_PREFIX) - else -> null - } - - return if (wcUri.isNullOrBlank()) { - false - } else { - val decodedWcUri = try { - URLDecoder.decode(wcUri, DEFAULT_CHARSET_NAME) - } catch (e: Exception) { - Timber.e(e) - return false - } - store.dispatchOnMain(WalletConnectAction.HandleDeepLink(decodedWcUri)) - true - } - } - - private companion object { - const val TANGEM_SCHEME = "tangem" - const val TANGEM_WC_PREFIX = "tangem://wc?uri=" - const val DEFAULT_CHARSET_NAME = "UTF-8" - const val WC_SCHEME = "wc" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt index c3567c8f3a..546cf5fd21 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt @@ -2,12 +2,15 @@ package com.tangem.tap.features.onboarding.products.wallet.redux import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.Analytics -import com.tangem.tap.* -import com.tangem.tap.common.analytics.events.Onboarding.* -import com.tangem.tap.common.extensions.* +import com.tangem.tap.backupService +import com.tangem.tap.common.analytics.events.Onboarding.Finished +import com.tangem.tap.common.extensions.dispatchNavigationAction +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.demo.DemoHelper +import com.tangem.tap.mainScope import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.store import kotlinx.coroutines.launch import org.rekotlin.Middleware @@ -24,7 +27,7 @@ class BackupMiddleware { @Suppress("LongMethod", "ComplexMethod", "MagicNumber") private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) { - if (DemoHelper.tryHandle(appState, action)) return + if (DemoHelper.tryHandle(appState)) return when (action) { is BackupAction.DiscardBackup -> { diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt index d2f1baf710..2aa7a3ef24 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt @@ -30,7 +30,7 @@ object TradeCryptoMiddleware { } private fun handle(state: () -> AppState?, action: TradeCryptoAction) { - if (DemoHelper.tryHandle(state, action)) return + if (DemoHelper.tryHandle(state)) return when (action) { is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId) diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt index 8a842072d8..d107bc7422 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt @@ -20,7 +20,6 @@ import com.tangem.domain.wallets.legacy.unlockIfLockable import com.tangem.tap.* import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler import com.tangem.tap.proxy.redux.DaggerGraphState import kotlinx.coroutines.launch import org.rekotlin.Middleware @@ -32,21 +31,19 @@ internal class WelcomeMiddleware { { action -> val appState = appStateProvider() if (action is WelcomeAction && appState != null) { - handleAction(action, appState.welcomeState) + handleAction(action) } next(action) } } } - private fun handleAction(action: WelcomeAction, state: WelcomeState) { + private fun handleAction(action: WelcomeAction) { mainScope.launch { when (action) { is WelcomeAction.ProceedWithIntent -> proceedWithIntent(action.intent) - is WelcomeAction.ProceedWithBiometrics -> proceedWithBiometrics( - afterUnlockIntent = action.afterUnlockIntent ?: state.intent, - ) - is WelcomeAction.ProceedWithCard -> proceedWithCard(afterScanIntent = state.intent) + is WelcomeAction.ProceedWithBiometrics -> proceedWithBiometrics() + is WelcomeAction.ProceedWithCard -> proceedWithCard() is WelcomeAction.ClearUserWallets -> disableUserWalletsSaving() else -> Unit } @@ -70,13 +67,8 @@ internal class WelcomeMiddleware { } } - private suspend fun proceedWithBiometrics(afterUnlockIntent: Intent?) { - Timber.d( - """ - Proceeding with biometry - |- Intent: $afterUnlockIntent - """.trimIndent(), - ) + private suspend fun proceedWithBiometrics() { + Timber.d("Proceeding with biometry") val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) userWalletsListManager.unlockIfLockable(type = UnlockType.ANY) @@ -93,20 +85,11 @@ internal class WelcomeMiddleware { store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) } store.dispatchWithMain(WelcomeAction.ProceedWithBiometrics.Success) store.onUserWalletSelected(userWallet = selectedUserWallet) - - afterUnlockIntent?.let { - WalletConnectLinkIntentHandler().handleIntent(it, false) - } } } - private suspend fun proceedWithCard(afterScanIntent: Intent?) { - Timber.d( - """ - Proceeding with card - |- Intent: $afterScanIntent - """.trimIndent(), - ) + private suspend fun proceedWithCard() { + Timber.d("Proceeding with card") scanCardInternal { scanResponse -> val userWalletBuilder = store.inject(DaggerGraphState::coldUserWalletBuilderFactory).create(scanResponse) @@ -125,10 +108,6 @@ internal class WelcomeMiddleware { store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) } store.dispatchWithMain(WelcomeAction.ProceedWithCard.Success) store.onUserWalletSelected(userWallet = userWallet) - - afterScanIntent?.let { - WalletConnectLinkIntentHandler().handleIntent(it, false) - } } } } diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt index 86116d6849..ef0fab7352 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt @@ -2,14 +2,12 @@ package com.tangem.tap.proxy.redux import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor import org.rekotlin.Action sealed interface DaggerGraphAction : Action { data class SetActivityDependencies( val scanCardUseCase: ScanCardUseCase, - val walletConnectInteractor: WalletConnectInteractor, val cardSdkConfigRepository: CardSdkConfigRepository, ) : DaggerGraphAction } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt index 76802dd20d..6a2e43b96a 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt @@ -14,7 +14,6 @@ object DaggerGraphReducer { return when (action) { is DaggerGraphAction.SetActivityDependencies -> state.daggerGraphState.copy( scanCardUseCase = action.scanCardUseCase, - walletConnectInteractor = action.walletConnectInteractor, cardSdkConfigRepository = action.cardSdkConfigRepository, ) } diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index f7e6c83846..ef68891955 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -37,8 +37,6 @@ import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.hot.sdk.TangemHotSdk import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor import com.tangem.tap.proxy.AppStateHolder import org.rekotlin.StateType @@ -46,8 +44,6 @@ data class DaggerGraphState( val networkConnectionManager: NetworkConnectionManager? = null, val cardScanningFeatureToggles: CardScanningFeatureToggles? = null, val scanCardUseCase: ScanCardUseCase? = null, - val walletConnectRepository: LegacyWalletConnectRepository? = null, - val walletConnectInteractor: WalletConnectInteractor? = null, val scanCardProcessor: ScanCardProcessor? = null, val cardSdkConfigRepository: CardSdkConfigRepository? = null, val appCurrencyRepository: AppCurrencyRepository? = null, 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 653b45d4c1..29a693971b 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 @@ -8,22 +8,14 @@ import com.tangem.feature.referral.api.ReferralComponent import com.tangem.feature.stories.api.StoriesComponent import com.tangem.feature.usedesk.api.UsedeskComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent -import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.AccountDetailsComponent +import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.createwalletselection.CreateWalletSelectionComponent import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.home.api.HomeComponent -import com.tangem.features.hotwallet.AddExistingWalletComponent -import com.tangem.features.hotwallet.CreateMobileWalletComponent -import com.tangem.features.hotwallet.UpgradeWalletComponent -import com.tangem.features.hotwallet.WalletActivationComponent -import com.tangem.features.hotwallet.CreateWalletBackupComponent -import com.tangem.features.hotwallet.UpdateAccessCodeComponent -import com.tangem.features.hotwallet.HotWalletFeatureToggles -import com.tangem.features.hotwallet.WalletBackupComponent -import com.tangem.features.hotwallet.ViewPhraseComponent +import com.tangem.features.hotwallet.* import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensSource @@ -45,14 +37,12 @@ import com.tangem.features.tangempay.components.TangemPayDetailsComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.coderecovery.api.AccessCodeRecoveryComponent import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent -import com.tangem.tap.features.details.ui.walletconnect.api.WalletConnectComponent import com.tangem.tap.features.welcome.component.WelcomeComponent import com.tangem.tap.routing.component.RoutingComponent.Child import com.tangem.features.tangempay.components.TangemPayOnboardingComponent @@ -84,7 +74,6 @@ internal class ChildFactory @Inject constructor( private val swapComponentFactory: SwapComponent.Factory, private val homeComponentFactory: HomeComponent.Factory, private val tokenDetailsComponentFactory: TokenDetailsComponent.Factory, - private val walletConnectComponentFactory: WalletConnectComponent.Factory, private val qrScanningComponentFactory: QrScanningComponent.Factory, private val accessCodeRecoveryComponentFactory: AccessCodeRecoveryComponent.Factory, private val cardSettingsComponentFactory: CardSettingsComponent.Factory, @@ -116,7 +105,6 @@ internal class ChildFactory @Inject constructor( private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, - private val walletConnectFeatureToggles: WalletConnectFeatureToggles, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { @@ -329,19 +317,11 @@ internal class ChildFactory @Inject constructor( ) } is AppRoute.WalletConnectSessions -> { - if (walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { - createComponentChild( - context = context, - params = RedesignedWalletConnectComponent.Params(route.userWalletId), - componentFactory = redesignedWalletConnectComponentFactory, - ) - } else { - createComponentChild( - context = context, - params = WalletConnectComponent.Params(route.userWalletId), - componentFactory = walletConnectComponentFactory, - ) - } + createComponentChild( + context = context, + params = RedesignedWalletConnectComponent.Params(route.userWalletId), + componentFactory = redesignedWalletConnectComponentFactory, + ) } is AppRoute.QrScanning -> { val source = when (route.source) { 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 ec0d4d8507..b22c123693 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 @@ -19,10 +19,6 @@ "name": "STAKING_CARDANO_ENABLED", "version": "undefined" }, - { - "name": "WALLET_CONNECT_REDESIGN_ENABLED", - "version": "5.27.0" - }, { "name": "USEDESK_ENABLED", "version": "undefined" diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index 0fe3412528..a83cefd1ae 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -24,7 +24,6 @@ import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.WcRequestUseCaseFactory -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletconnect.repository.WalletConnectRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.disconnect.WcDisconnectUseCase @@ -88,7 +87,6 @@ internal object WalletConnectDataModule { fun defaultWcSessionsManager( store: WalletConnectStore, dispatchers: CoroutineDispatcherProvider, - legacyStore: WalletConnectSessionsRepository, getWallets: GetWalletsUseCase, wcNetworksConverter: WcNetworksConverter, analytics: AnalyticsEventHandler, @@ -97,7 +95,6 @@ internal object WalletConnectDataModule { return DefaultWcSessionsManager( store = store, dispatchers = dispatchers, - legacyStore = legacyStore, getWallets = getWallets, wcNetworksConverter = wcNetworksConverter, analytics = analytics, diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt index 69aa56551c..8697778af1 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -3,7 +3,6 @@ package com.tangem.data.walletconnect.sessions import arrow.core.Either import arrow.core.left import arrow.core.right -import com.domain.blockaid.models.dapp.CheckDAppResult import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -16,7 +15,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionDTO -import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -30,7 +28,6 @@ import kotlin.coroutines.resume @Suppress("LongParameterList") internal class DefaultWcSessionsManager( private val store: WalletConnectStore, - private val legacyStore: WalletConnectSessionsRepository, private val getWallets: GetWalletsUseCase, private val dispatchers: CoroutineDispatcherProvider, private val wcNetworksConverter: WcNetworksConverter, @@ -39,18 +36,12 @@ internal class DefaultWcSessionsManager( ) : WcSessionsManager, WcSdkObserver { private val onSessionDelete = Channel(capacity = Channel.BUFFERED) - private val oneTimeMigration = MutableStateFlow(true) override val sessions: Flow>> get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore } .transform { pair -> val (wallets, inStore) = pair val inSdk: List = WalletKit.getListOfActiveSessions() - if (oneTimeMigration.value) { - oneTimeMigration.value = false - val someMigrated = migrateLegacyStore(inStore, inSdk, wallets) - if (someMigrated) return@transform // ignore emit, wait next one - } val associatedSessions: List = associate(inSdk, inStore, wallets) val someRemove = removeUnknownSessions(inStore, inSdk, associatedSessions) if (someRemove) return@transform // ignore emit, wait next one @@ -60,7 +51,6 @@ internal class DefaultWcSessionsManager( .flowOn(dispatchers.io) override fun onWcSdkInit() { - oneTimeMigration.value = true listenOnSessionDelete() extendSessions() } @@ -96,39 +86,6 @@ internal class DefaultWcSessionsManager( onSessionDelete.trySend(sessionDelete) } - private suspend fun migrateLegacyStore( - inNewStore: Set, - inSdk: List, - wallets: List, - ): Boolean { - val walletIds = wallets.map { wallet -> wallet.walletId } - val inLegacyStore = walletIds - .map { walletId -> - flow { - emit( - legacyStore.loadSessions(walletId.stringValue).mapNotNull { legacySession -> - val url = - inSdk.find { it.topic == legacySession.topic }?.metaData?.url ?: return@mapNotNull null - WcSessionDTO( - topic = legacySession.topic, - walletId = walletId, - url = url, - securityStatus = CheckDAppResult.FAILED_TO_VERIFY, - ) - }, - ) - } - } - .merge() - .reduce { accumulator, value -> accumulator.plus(value) } - // migrate only active legacySessions - .filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } } - - val mustSaveInNewStore = inLegacyStore.subtract(inNewStore) - if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore) - return mustSaveInNewStore.isNotEmpty() - } - private suspend fun associate( inSdk: List, inStore: Set, diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt deleted file mode 100644 index 8c370b9fbd..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.domain.walletconnect.model.legacy - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class Account( - @Json(name = "chainId") - val chainId: String, - - @Json(name = "walletAddress") - val walletAddress: String, - - @Json(name = "derivationPath") - val derivationPath: String?, -) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt deleted file mode 100644 index 5ca9f80ed4..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.walletconnect.model.legacy - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class Session( - @Json(name = "topic") - val topic: String, - - @Json(name = "accounts") - val accounts: List, -) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt deleted file mode 100644 index dce7f7f080..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.domain.walletconnect.model.legacy - -interface WalletConnectSessionsRepository { - suspend fun loadSessions(userWallet: String): List - - suspend fun saveSession(userWallet: String, session: Session) - - suspend fun removeSession(userWallet: String, topic: String) -} \ No newline at end of file diff --git a/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectFeatureToggles.kt b/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectFeatureToggles.kt deleted file mode 100644 index 3b9e35b56a..0000000000 --- a/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.walletconnect.components - -interface WalletConnectFeatureToggles { - val isRedesignedWalletConnectEnabled: Boolean -} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt index 5524f954e3..341ab307f2 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt @@ -10,7 +10,6 @@ import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.model.WcEthMethodName import com.tangem.domain.walletconnect.model.WcMethodName import com.tangem.domain.walletconnect.model.WcSolanaMethodName -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -21,18 +20,15 @@ internal class WcRoutingModel @Inject constructor( private val pairService: WcPairService, private val cardSdkProvider: CardSdkProvider, override val dispatchers: CoroutineDispatcherProvider, - private val featureToggles: WalletConnectFeatureToggles, ) : Model() { - val innerRouter = WcRouter(SlotNavigation()) + val innerRouter = WcRouter(SlotNavigation()) private val isSlotEmpty = MutableStateFlow(true) private val permittedAppRoute = MutableStateFlow(false) init { - if (featureToggles.isRedesignedWalletConnectEnabled) { - setupQueue() - } + setupQueue() } fun onSlotEmpty() { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt index 53c98210ad..5d82c115da 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/deeplink/DefaultWalletConnectDeepLinkHandler.kt @@ -4,7 +4,6 @@ import android.net.Uri import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.features.walletconnect.components.WalletConnectFeatureToggles import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -15,7 +14,6 @@ import java.net.URLDecoder internal class DefaultWalletConnectDeepLinkHandler @AssistedInject constructor( @Assisted uri: Uri, getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - walletConnectFeatureToggles: WalletConnectFeatureToggles, wcPairService: WcPairService, ) : WalletConnectDeepLinkHandler { @@ -30,15 +28,13 @@ internal class DefaultWalletConnectDeepLinkHandler @AssistedInject constructor( }, ifRight = { wallet -> val decodedWcUri = URLDecoder.decode(wcUri, DEFAULT_CHARSET_NAME) - if (walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { - wcPairService.pair( - request = WcPairRequest( - uri = decodedWcUri, - source = WcPairRequest.Source.DEEPLINK, - userWalletId = wallet.walletId, - ), - ) - } + wcPairService.pair( + request = WcPairRequest( + uri = decodedWcUri, + source = WcPairRequest.Source.DEEPLINK, + userWalletId = wallet.walletId, + ), + ) }, ) } catch (e: Exception) { diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 37e38fce69..559f845100 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -63,7 +63,6 @@ hilt-work = "1.0.0" hilt-compilerx = "1.0.0" jodatime = "2.12.1" kotlin-immutable-collections = "0.3.5" -kotsonGsonExt = "2.5.0" lottie = "3.4.0" lottie-compose = "6.6.0" moshi = "1.15.1" @@ -252,7 +251,6 @@ hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" } hilt-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-compilerx = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-compilerx" } jodatime = { module = "joda-time:joda-time", version.ref = "jodatime" } -kotsonGson = { module = "com.github.salomonbrys.kotson:kotson", version.ref = "kotsonGsonExt" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie-compose" } material = { module = "com.google.android.material:material", version.ref = "googleMaterialComponent" } From 04bcb44f7619806e147ac0793b817a0ab96de821 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Sep 2025 19:12:30 +0400 Subject: [PATCH 058/132] Updated on 2026-08-14 --- .../api/common/response/ApiResponse.kt | 51 ++++++++++++------- .../response/ApiResponseCallDelegate.kt | 3 +- .../api/common/response/ApiResponseError.kt | 6 +-- .../api/common/response/ApiResponseExt.kt | 10 ++++ .../api/common/response/ApiResponseHeaders.kt | 3 ++ .../api/common/response/ResponseExt.kt | 7 +-- 6 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseHeaders.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt index 0348dba0e3..b85ea4e10e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt @@ -7,34 +7,51 @@ package com.tangem.datasource.api.common.response */ sealed class ApiResponse { - /** - * Represents a successful response from the API. - * - * @property data The data returned by the API. - */ - data class Success(val data: T) : ApiResponse() + /** Map of headers (header name with list of values) */ + abstract val headers: Map> /** - * Represents an error response or failure from the API. + * Represents a successful response from the API * - * @property cause The cause of the error. + * @property data the data returned by the API + * @property headers the headers returned by the API */ - data class Error(val cause: ApiResponseError) : ApiResponse() + data class Success( + val data: T, + override val headers: Map> = emptyMap(), + ) : ApiResponse() + + /** + * Represents an error response or failure from the API + * + * @property cause the cause of the error + * @property headers the headers returned by the API + */ + data class Error( + val cause: ApiResponseError, + override val headers: Map> = emptyMap(), + ) : ApiResponse() } /** - * Wraps data in a [ApiResponse.Success] instance. + * Wraps data in a [ApiResponse.Success] instance * - * @param data The data to wrap. - * @return A [ApiResponse.Success] instance containing the provided data. + * @param data the data to wrap + * @param headers the headers returned by the API + * @return a [ApiResponse.Success] instance containing the provided data */ -internal fun apiSuccess(data: T): ApiResponse = ApiResponse.Success(data) +internal fun apiSuccess(data: T, headers: Map>): ApiResponse { + return ApiResponse.Success(data, headers) +} /** - * Wraps an [ApiResponseError] in a [ApiResponse.Error] instance. + * Wraps an [ApiResponseError] in a [ApiResponse.Error] instance * - * @param cause The error to wrap. - * @return A [ApiResponse.Error] instance containing the provided error. + * @param cause the error to wrap + * @param headers the headers returned by the API + * @return a [ApiResponse.Error] instance containing the provided error */ @Suppress("UNCHECKED_CAST") -internal fun apiError(cause: ApiResponseError): ApiResponse = ApiResponse.Error(cause) as ApiResponse \ No newline at end of file +internal fun apiError(cause: ApiResponseError, headers: Map>): ApiResponse { + return ApiResponse.Error(cause, headers) as ApiResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt index 50ee235c7b..28c7fa4f72 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt @@ -47,7 +47,8 @@ internal class ApiResponseCallDelegate( Timber.e(e, "onFailure UnknownException") ApiResponseError.UnknownException(e) } - val safeResponse = apiError(error) + + val safeResponse = apiError(cause = error, headers = emptyMap()) responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse)) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt index 10ec8add5d..f63826874b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt @@ -20,6 +20,8 @@ sealed class ApiResponseError : Exception() { // region Error Codes enum class Code(val numericCode: Int) { + // 3xx Server Errors + NOT_MODIFIED(numericCode = 304), // 4xx Server Errors BAD_REQUEST(numericCode = 400), UNAUTHORIZED(numericCode = 401), @@ -64,10 +66,6 @@ sealed class ApiResponseError : Exception() { ; override fun toString(): String = "$numericCode - $name" - - companion object { - val values = values() - } } // endregion Error Codes } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt index 0caae8ba1a..41a9f6bc8f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt @@ -19,4 +19,14 @@ inline fun catchApiResponseError(onError: (ApiResponseError) -> Unit, block: onError(e) throw e } +} + +/** + * Checks if the [ApiResponseError] is a network-related error. + * You can provide a custom predicate [codePredicate] to check for specific HTTP status codes. + */ +inline fun ApiResponseError.isNetworkError( + codePredicate: (ApiResponseError.HttpException.Code) -> Boolean = { true }, +): Boolean { + return this is ApiResponseError.HttpException && codePredicate(this.code) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseHeaders.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseHeaders.kt new file mode 100644 index 0000000000..52464501c9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseHeaders.kt @@ -0,0 +1,3 @@ +package com.tangem.datasource.api.common.response + +const val IF_NONE_MATCH_HEADER = "IfNoneMatch" \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt index 926d9213aa..a8272de6f1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt @@ -12,12 +12,13 @@ import java.util.concurrent.TimeoutException import javax.net.ssl.SSLHandshakeException internal fun Response.toSafeApiResponse(analyticsErrorHandler: AnalyticsErrorHandler): ApiResponse { + val headers = headers().toMultimap() val body = body() return if (isSuccessful && body != null) { - apiSuccess(body) + apiSuccess(data = body, headers = headers) } else { - val code = ApiResponseError.HttpException.Code.values + val code = ApiResponseError.HttpException.Code.entries .firstOrNull { it.numericCode == code() } val e = try { if (code == null) { @@ -33,7 +34,7 @@ internal fun Response.toSafeApiResponse(analyticsErrorHandler: Anal ApiResponseError.UnknownException(e) } - apiError(e) + apiError(e, headers) } } From e5999953155e4b7d0360642bcccc6e4eda32585a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 8 Sep 2025 14:33:06 +0500 Subject: [PATCH 059/132] Updated on 2026-08-14 --- .../com/tangem/tap/LockUserWalletsTimer.kt | 7 +++++ .../main/java/com/tangem/tap/MainActivity.kt | 5 ++++ .../tap/di/domain/WalletsDomainModule.kt | 20 +++++++++++++ .../wallets/hot/DefaultHotWalletAccessor.kt | 11 +++++++ .../domain/wallets/hot/HotWalletAccessor.kt | 4 +++ ...learAllHotWalletContextualUnlockUseCase.kt | 16 ++++++++++ .../GetHotWalletContextualUnlockUseCase.kt | 17 +++++++++++ .../accesscode/AccessCodeComponent.kt | 5 ++-- .../hotwallet/accesscode/AccessCodeModel.kt | 30 +++++++++++++------ .../entry/AddExistingWalletModel.kt | 4 +-- .../routing/AddExistingWalletChildFactory.kt | 2 -- .../updateaccesscode/UpdateAccessCodeModel.kt | 4 +-- .../routing/UpdateAccessCodeChildFactory.kt | 2 -- .../routing/UpdateAccessCodeRoute.kt | 9 ++++-- .../entry/WalletActivationModel.kt | 4 +-- .../routing/WalletActivationChildFactory.kt | 2 -- .../model/WalletSettingsModel.kt | 28 +++++++++++++++-- gradle/tangem_dependencies.toml | 2 +- 18 files changed, 142 insertions(+), 30 deletions(-) create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearAllHotWalletContextualUnlockUseCase.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetHotWalletContextualUnlockUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt index e3d0a18067..4a26816830 100644 --- a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt +++ b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt @@ -11,6 +11,7 @@ import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.asLockable import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.tap.LockTimerWorker.Companion.TAG import com.tangem.tap.common.extensions.dispatchNavigationAction @@ -22,6 +23,7 @@ import timber.log.Timber import java.util.concurrent.TimeUnit import kotlin.time.Duration +@Suppress("LongParameterList") internal class LockUserWalletsTimer( private val context: Context, private val settingsRepository: SettingsRepository, @@ -30,6 +32,7 @@ internal class LockUserWalletsTimer( private val userWalletsListRepository: UserWalletsListRepository, private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val coroutineScope: CoroutineScope, + private val clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase, ) : LifecycleOwner by context as LifecycleOwner, DefaultLifecycleObserver { @@ -55,6 +58,9 @@ internal class LockUserWalletsTimer( ) if (shouldOpenWelcomeScreenOnResume) { + if (hotWalletFeatureToggles.isHotWalletEnabled) { + clearAllHotWalletContextualUnlockUseCase.invoke() + } store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } settingsRepository.setShouldOpenWelcomeScreenOnResume(value = false) } @@ -120,6 +126,7 @@ internal class LockUserWalletsTimer( start() } .onRight { + clearAllHotWalletContextualUnlockUseCase.invoke() store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } } } diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 7217bbef8d..287b4a7688 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -50,6 +50,7 @@ import com.tangem.domain.staking.SendUnsubmittedHashesUseCase import com.tangem.domain.tokens.GetPolkadotCheckHasImmortalUseCase import com.tangem.domain.tokens.GetPolkadotCheckHasResetUseCase import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.tangempay.TangemPayFeatureToggles @@ -186,6 +187,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var hotWalletFeatureToggles: HotWalletFeatureToggles + @Inject + internal lateinit var clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase + @Inject internal lateinit var tangemPayFeatureToggles: TangemPayFeatureToggles @@ -274,6 +278,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { coroutineScope = mainScope, userWalletsListRepository = userWalletsListRepository, hotWalletFeatureToggles = hotWalletFeatureToggles, + clearAllHotWalletContextualUnlockUseCase = clearAllHotWalletContextualUnlockUseCase, ) initIntentHandlers() diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index 0f1f035a13..c116c9d81f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -384,6 +384,16 @@ internal object WalletsDomainModule { ) } + @Provides + @Singleton + fun providesGetHotWalletContextualUnlockUseCase( + hotWalletAccessor: HotWalletAccessor, + ): GetHotWalletContextualUnlockUseCase { + return GetHotWalletContextualUnlockUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + @Provides @Singleton fun providesClearHotWalletContextualUnlockUseCase( @@ -394,6 +404,16 @@ internal object WalletsDomainModule { ) } + @Provides + @Singleton + fun providesClearAllHotWalletContextualUnlockUseCase( + hotWalletAccessor: HotWalletAccessor, + ): ClearAllHotWalletContextualUnlockUseCase { + return ClearAllHotWalletContextualUnlockUseCase( + hotWalletAccessor = hotWalletAccessor, + ) + } + @Provides @Singleton fun providesExportSeedPhraseUseCase(hotWalletAccessor: HotWalletAccessor): ExportSeedPhraseUseCase { diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index 70273f2684..33dc7635a7 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -52,6 +52,9 @@ class DefaultHotWalletAccessor @Inject constructor( } } + override fun getContextualUnlock(hotWalletId: HotWalletId): UnlockHotWallet? = + contextualUnlockHotWallet[hotWalletId] + override fun clearContextualUnlock(hotWalletId: HotWalletId) { contextualUnlockHotWallet[hotWalletId] = null scope.launch { @@ -59,6 +62,14 @@ class DefaultHotWalletAccessor @Inject constructor( } } + override fun clearAllContextualUnlock() { + val hotWalletsIds = contextualUnlockHotWallet.keys.toList() + contextualUnlockHotWallet.clear() + scope.launch { + hotWalletsIds.forEach { tangemHotSdk.clearUnlockContext(it) } + } + } + private suspend fun hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T { val isAccessCodeRequired = walletsRepository.requireAccessCode() diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt index f0297ced18..41fc7f5fcb 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt @@ -12,5 +12,9 @@ interface HotWalletAccessor { suspend fun unlockContextual(hotWalletId: HotWalletId): UnlockHotWallet + fun getContextualUnlock(hotWalletId: HotWalletId): UnlockHotWallet? + fun clearContextualUnlock(hotWalletId: HotWalletId) + + fun clearAllContextualUnlock() } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearAllHotWalletContextualUnlockUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearAllHotWalletContextualUnlockUseCase.kt new file mode 100644 index 0000000000..0dddccd934 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearAllHotWalletContextualUnlockUseCase.kt @@ -0,0 +1,16 @@ + +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor + +class ClearAllHotWalletContextualUnlockUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + operator fun invoke(): Either { + return Either.catch { + hotWalletAccessor.clearAllContextualUnlock() + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetHotWalletContextualUnlockUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetHotWalletContextualUnlockUseCase.kt new file mode 100644 index 0000000000..629c4cad06 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetHotWalletContextualUnlockUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.hot.HotWalletAccessor +import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.hot.sdk.model.UnlockHotWallet + +class GetHotWalletContextualUnlockUseCase( + private val hotWalletAccessor: HotWalletAccessor, +) { + + suspend operator fun invoke(hotWalletId: HotWalletId): Either { + return Either.catch { + hotWalletAccessor.getContextualUnlock(hotWalletId) + } + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt index 5a044f85ef..160d1a228f 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt @@ -34,12 +34,11 @@ internal class AccessCodeComponent @AssistedInject constructor( } interface ModelCallbacks { - fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) - fun onAccessCodeConfirmed(userWalletId: UserWalletId) + fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) + fun onAccessCodeUpdated(userWalletId: UserWalletId) } data class Params( - val isConfirmMode: Boolean, val accessCodeToConfirm: String? = null, val userWalletId: UserWalletId, val callbacks: ModelCallbacks, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt index a3d0e3c9d7..e611297547 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt @@ -16,11 +16,14 @@ import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.SetAskBiometryShownUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase +import com.tangem.domain.wallets.usecase.GetHotWalletContextualUnlockUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM import com.tangem.features.hotwallet.impl.R import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.UnlockHotWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow @@ -38,6 +41,8 @@ internal class AccessCodeModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, + private val getHotWalletContextualUnlockUseCase: GetHotWalletContextualUnlockUseCase, + private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, private val userWalletsListRepository: UserWalletsListRepository, private val walletsRepository: WalletsRepository, private val tangemHotSdk: TangemHotSdk, @@ -49,13 +54,15 @@ internal class AccessCodeModel @Inject constructor( private val params = paramsContainer.require() + private var hotWalletId: HotWalletId? = null + internal val uiState: StateFlow field = MutableStateFlow(getInitialState()) private fun getInitialState() = AccessCodeUM( accessCode = "", onAccessCodeChange = ::onAccessCodeChange, - isConfirmMode = params.isConfirmMode, + isConfirmMode = params.accessCodeToConfirm != null, buttonEnabled = false, buttonInProgress = false, onButtonClick = ::onButtonClick, @@ -65,7 +72,7 @@ internal class AccessCodeModel @Inject constructor( uiState.update { it.copy( accessCode = value, - buttonEnabled = if (params.isConfirmMode) { + buttonEnabled = if (params.accessCodeToConfirm != null) { value == params.accessCodeToConfirm } else { value.length == uiState.value.accessCodeLength @@ -75,12 +82,10 @@ internal class AccessCodeModel @Inject constructor( } private fun onButtonClick() { - if (!params.isConfirmMode) { - params.callbacks.onAccessCodeSet(params.userWalletId, uiState.value.accessCode) + if (params.accessCodeToConfirm == null) { + params.callbacks.onNewAccessCodeInput(params.userWalletId, uiState.value.accessCode) } else { - params.accessCodeToConfirm?.let { - setCode(params.userWalletId, it) - } + setCode(params.userWalletId, params.accessCodeToConfirm) } } @@ -95,7 +100,9 @@ internal class AccessCodeModel @Inject constructor( .getOrElse { error("User wallet with id $userWalletId not found") } if (userWallet !is UserWallet.Hot) return@launch - val unlockHotWallet = UnlockHotWallet(userWallet.hotWalletId, HotAuth.NoAuth) + val unlockHotWallet = getHotWalletContextualUnlockUseCase(userWallet.hotWalletId) + .getOrNull() + ?: UnlockHotWallet(userWallet.hotWalletId, HotAuth.NoAuth) var updatedHotWalletId = tangemHotSdk.changeAuth( unlockHotWallet = unlockHotWallet, auth = HotAuth.Password(accessCode.toCharArray()), @@ -133,7 +140,7 @@ internal class AccessCodeModel @Inject constructor( ) } - params.callbacks.onAccessCodeConfirmed(params.userWalletId) + params.callbacks.onAccessCodeUpdated(params.userWalletId) }.onFailure { Timber.e(it) @@ -188,4 +195,9 @@ internal class AccessCodeModel @Inject constructor( return canUseBiometry && shouldShowAskBiometry } + + override fun onDestroy() { + hotWalletId?.let { clearHotWalletContextualUnlockUseCase.invoke(it) } + super.onDestroy() + } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt index c562032c4a..b218fa6aea 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt @@ -127,11 +127,11 @@ internal class AddExistingWalletModel @Inject constructor( } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { - override fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) { + override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { stackNavigation.push(AddExistingWalletRoute.ConfirmAccessCode(userWalletId, accessCode)) } - override fun onAccessCodeConfirmed(userWalletId: UserWalletId) { + override fun onAccessCodeUpdated(userWalletId: UserWalletId) { navigateToPushNotificationsOrNext() } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt index de7e7a76f2..6393ff0d6d 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt @@ -46,7 +46,6 @@ internal class AddExistingWalletChildFactory @Inject constructor( is AddExistingWalletRoute.SetAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = false, userWalletId = route.userWalletId, callbacks = model.accessCodeModelCallbacks, ), @@ -54,7 +53,6 @@ internal class AddExistingWalletChildFactory @Inject constructor( is AddExistingWalletRoute.ConfirmAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = true, accessCodeToConfirm = route.accessCode, userWalletId = route.userWalletId, callbacks = model.accessCodeModelCallbacks, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt index 19f8947793..5cf4e05649 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt @@ -35,11 +35,11 @@ internal class UpdateAccessCodeModel @Inject constructor( } } - override fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) { + override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { stackNavigation.push(UpdateAccessCodeRoute.ConfirmAccessCode(userWalletId, accessCode)) } - override fun onAccessCodeConfirmed(userWalletId: UserWalletId) { + override fun onAccessCodeUpdated(userWalletId: UserWalletId) { router.pop() } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt index 354156a4c0..968560b8b3 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt @@ -19,7 +19,6 @@ internal class UpdateAccessCodeChildFactory @Inject constructor( is UpdateAccessCodeRoute.SetAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = false, userWalletId = route.userWalletId, callbacks = model, ), @@ -27,7 +26,6 @@ internal class UpdateAccessCodeChildFactory @Inject constructor( is UpdateAccessCodeRoute.ConfirmAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = true, accessCodeToConfirm = route.accessCode, userWalletId = route.userWalletId, callbacks = model, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt index a414995364..31d9113158 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt @@ -7,8 +7,13 @@ import kotlinx.serialization.Serializable internal sealed class UpdateAccessCodeRoute : Route { @Serializable - data class SetAccessCode(val userWalletId: UserWalletId) : UpdateAccessCodeRoute() + data class SetAccessCode( + val userWalletId: UserWalletId, + ) : UpdateAccessCodeRoute() @Serializable - data class ConfirmAccessCode(val userWalletId: UserWalletId, val accessCode: String) : UpdateAccessCodeRoute() + data class ConfirmAccessCode( + val userWalletId: UserWalletId, + val accessCode: String, + ) : UpdateAccessCodeRoute() } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt index 722d2a7480..cfd50e4c72 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt @@ -141,11 +141,11 @@ internal class WalletActivationModel @Inject constructor( } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { - override fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) { + override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { stackNavigation.push(WalletActivationRoute.ConfirmAccessCode(accessCode)) } - override fun onAccessCodeConfirmed(userWalletId: UserWalletId) { + override fun onAccessCodeUpdated(userWalletId: UserWalletId) { navigateToPushNotificationsOrNext() } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt index e6c13bdbd9..77684cecdb 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt @@ -55,7 +55,6 @@ internal class WalletActivationChildFactory @Inject constructor( is WalletActivationRoute.SetAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = false, userWalletId = model.params.userWalletId, callbacks = model.accessCodeModelCallbacks, ), @@ -63,7 +62,6 @@ internal class WalletActivationChildFactory @Inject constructor( is WalletActivationRoute.ConfirmAccessCode -> accessCodeComponentFactory.create( context = childContext, params = AccessCodeComponent.Params( - isConfirmMode = true, accessCodeToConfirm = route.accessCode, userWalletId = model.params.userWalletId, callbacks = model.accessCodeModelCallbacks, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index fac9ee92d6..3257456f21 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -46,6 +46,7 @@ import com.tangem.feature.walletsettings.utils.AccountItemsDelegate import com.tangem.feature.walletsettings.utils.ItemsBuilder import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf @@ -57,7 +58,6 @@ import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @ModelScoped internal class WalletSettingsModel @Inject constructor( - getWalletUseCase: GetUserWalletUseCase, paramsContainer: ParamsContainer, private val router: Router, private val messageSender: UiMessageSender, @@ -73,6 +73,7 @@ internal class WalletSettingsModel @Inject constructor( private val enableWalletNFTUseCase: EnableWalletNFTUseCase, private val disableWalletNFTUseCase: DisableWalletNFTUseCase, getWalletNotificationsEnabledUseCase: GetWalletNotificationsEnabledUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, private val settingsManager: SettingsManager, private val permissionsRepository: PermissionRepository, @@ -80,6 +81,7 @@ internal class WalletSettingsModel @Inject constructor( private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, private val isUpgradeWalletNotificationEnabledUseCase: IsUpgradeWalletNotificationEnabledUseCase, private val dismissUpgradeWalletNotificationUseCase: DismissUpgradeWalletNotificationUseCase, + private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, ) : Model() { val params: WalletSettingsComponent.Params = paramsContainer.require() @@ -144,7 +146,7 @@ internal class WalletSettingsModel @Inject constructor( ) } } - getWalletUseCase.invokeFlow(params.userWalletId) + getUserWalletUseCase.invokeFlow(params.userWalletId) .distinctUntilChanged() .filterIsInstance>() .flatMapLatest { combineUI(it.value) } @@ -361,7 +363,27 @@ internal class WalletSettingsModel @Inject constructor( if (!state.value.isWalletBackedUp) { messageSender.send(makeBackupAtFirstAlertBS) } else { - router.push(AppRoute.UpdateAccessCode(params.userWalletId)) + val userWallet = getUserWalletUseCase(params.userWalletId) + .getOrElse { error("User wallet with id ${params.userWalletId} not found") } + if (userWallet is UserWallet.Hot) { + val hotWalletId = userWallet.hotWalletId + when (hotWalletId.authType) { + HotWalletId.AuthType.NoPassword -> { + router.push(AppRoute.UpdateAccessCode(params.userWalletId)) + } + HotWalletId.AuthType.Password, + HotWalletId.AuthType.Biometry, + -> modelScope.launch { + unlockHotWalletContextualUseCase.invoke(hotWalletId) + .onLeft { + Timber.e("Unable to unlock wallet with id ${params.userWalletId}") + } + .onRight { + router.push(AppRoute.UpdateAccessCode(params.userWalletId)) + } + } + } + } } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 80bdb6d116..4829355867 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -11,7 +11,7 @@ tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-526" +tangemHotSdk = "develop-527" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From f39054b5a985ef28039d33eaa42a19b2a1d4e487 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 7 Sep 2025 12:44:07 +0400 Subject: [PATCH 060/132] Updated on 2026-08-14 --- .../api/common/response/ApiResponseExt.kt | 11 +- .../account/GetWalletAccountsResponse.kt | 2 +- .../FetchWalletAccountsErrorHandler.kt | 141 ++++++++ .../utils/GetWalletAccountsResponseExt.kt | 57 +++ .../FetchWalletAccountsErrorHandlerTest.kt | 239 +++++++++++++ .../utils/GetWalletAccountsResponseExtTest.kt | 324 ++++++++++++++++++ .../UserTokensResponseAccountIdEnricher.kt | 33 +- 7 files changed, 788 insertions(+), 19 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt index 41a9f6bc8f..a6b364259c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt @@ -21,12 +21,7 @@ inline fun catchApiResponseError(onError: (ApiResponseError) -> Unit, block: } } -/** - * Checks if the [ApiResponseError] is a network-related error. - * You can provide a custom predicate [codePredicate] to check for specific HTTP status codes. - */ -inline fun ApiResponseError.isNetworkError( - codePredicate: (ApiResponseError.HttpException.Code) -> Boolean = { true }, -): Boolean { - return this is ApiResponseError.HttpException && codePredicate(this.code) +/** Checks if the ApiResponseError is a network error with the specified HTTP status [code] */ +fun ApiResponseError.isNetworkError(code: ApiResponseError.HttpException.Code): Boolean { + return this is ApiResponseError.HttpException && this.code == code } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt index 6c3afd812f..ac0ce437eb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt @@ -15,7 +15,7 @@ data class GetWalletAccountsResponse( @JsonClass(generateAdapter = true) data class Wallet( - @Json(name = "version") val version: Int, + @Json(name = "version") val version: Int = 0, @Json(name = "group") val group: GroupType, @Json(name = "sort") val sort: SortType, @Json(name = "totalAccounts") val totalAccounts: Int, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt new file mode 100644 index 0000000000..7142520d72 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -0,0 +1,141 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.Provider +import timber.log.Timber +import javax.inject.Inject + +/** + * Handles errors that occur during the fetching of wallet accounts + * + * @property userTokensSaver saves user tokens to the storage + * @property userWalletsStore provides access to user wallet data + * @property userTokensResponseStore provides access to user token responses. + * @property cryptoPortfolioCF factory for converting crypto portfolios + * @property userTokensResponseFactory factory for creating user token responses + * @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets + * + * @see DefaultWalletAccountsFetcher + * +[REDACTED_AUTHOR] + */ +internal class FetchWalletAccountsErrorHandler @Inject constructor( + private val userTokensSaver: UserTokensSaver, + private val userWalletsStore: UserWalletsStore, + private val userTokensResponseStore: UserTokensResponseStore, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userTokensResponseFactory: UserTokensResponseFactory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + /** + * Handles the error that occurred during the fetching of wallet accounts. + * [pushWalletAccounts] and [storeWalletAccounts] are functions that passed as parameters to avoid + * cyclic dependencies. + * + * @param error the error that occurred + * @param userWalletId the ID of the user wallet + * @param savedAccountsResponse the previously saved wallet accounts response, if available + * @param pushWalletAccounts function to push wallet accounts to the server + * @param storeWalletAccounts function to store wallet accounts locally + */ + suspend fun handle( + error: ApiResponseError, + userWalletId: UserWalletId, + savedAccountsResponse: GetWalletAccountsResponse?, + pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List) -> Unit, + storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit, + ) { + val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) + if (isResponseUpToDate) { + Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") + return + } + + val userWalletProvider = Provider { userWalletsStore.getSyncStrict(key = userWalletId) } + val accountDTOs = savedAccountsResponse?.accounts.orDefault(userWalletProvider = userWalletProvider) + + val userTokensResponse = savedAccountsResponse?.toUserTokensResponse() + .orFromLegacyStore(userWalletProvider = userWalletProvider) + .orDefault(userWalletProvider = userWalletProvider) + + val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) + if (isNotFoundError) { + pushWalletAccounts(userWalletId, accountDTOs) + userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) + } + + val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse) + storeWalletAccounts(userWalletId, response) + } + + private fun List?.orDefault(userWalletProvider: Provider): List { + if (this != null) return this + + val userWallet = userWalletProvider() + + val accounts = AccountList.empty(userWallet).accounts + .filterIsInstance() + + val converter = cryptoPortfolioCF.create(userWallet = userWallet) + + return converter.convertListBack(input = accounts) + } + + private suspend fun UserTokensResponse?.orFromLegacyStore( + userWalletProvider: Provider, + ): UserTokensResponse? { + if (this != null) return this + + return userTokensResponseStore.getSyncOrNull( + userWalletId = userWalletProvider().walletId, + ) + } + + private fun UserTokensResponse?.orDefault(userWalletProvider: Provider): UserTokensResponse { + if (this != null) return this + + return userTokensResponseFactory.createUserTokensResponse( + currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet( + userWallet = userWalletProvider(), + ), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + + private fun GetWalletAccountsResponse?.orDefault( + userWalletId: UserWalletId, + accountDTOs: List, + userTokensResponse: UserTokensResponse, + ): GetWalletAccountsResponse { + if (this != null) return this + + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = userTokensResponse.group, + sort = userTokensResponse.sort, + totalAccounts = accountDTOs.size, + ), + accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens), + unassignedTokens = emptyList(), + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt new file mode 100644 index 0000000000..caace442d6 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt @@ -0,0 +1,57 @@ +package com.tangem.data.account.utils + +import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.wallet.UserWalletId + +/** Flattens the tokens from all wallet accounts into a single list */ +internal fun GetWalletAccountsResponse.flattenTokens(): List { + return accounts.flatMap { it.tokens.orEmpty() } +} + +/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */ +internal fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse { + return UserTokensResponse( + group = wallet.group, + sort = wallet.sort, + tokens = flattenTokens(), + ) +} + +/** + * Assigns tokens from a [UserTokensResponse] to the wallet accounts in the [GetWalletAccountsResponse] + * + * @param userWalletId the ID of the user wallet + * + * @return a new [GetWalletAccountsResponse]` with tokens assigned to the wallet accounts + */ +internal fun GetWalletAccountsResponse.assignTokens(userWalletId: UserWalletId): GetWalletAccountsResponse { + return copy( + accounts = accounts.assignTokens(userWalletId = userWalletId, tokens = unassignedTokens), + unassignedTokens = emptyList(), + ) +} + +/** + * Assigns tokens from a [UserTokensResponse] to a list of wallet accounts + * + * @param userWalletId the ID of the user wallet + * @param tokens tokens to be assigned + * + * @return a new list of [WalletAccountDTO] with tokens assigned to each account + */ +internal fun List.assignTokens( + userWalletId: UserWalletId, + tokens: List, +): List { + val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) + .groupBy { it.accountId } + + return map { accountDTO -> + accountDTO.copy( + tokens = enrichedTokens[accountDTO.id].orEmpty(), + ) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt new file mode 100644 index 0000000000..5d41955371 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -0,0 +1,239 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FetchWalletAccountsErrorHandlerTest { + + private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) + private val userWalletsStore: UserWalletsStore = mockk() + private val userTokensResponseStore: UserTokensResponseStore = mockk() + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk() + private val cryptoPortfolioConverter = mockk() + private val userTokensResponseFactory: UserTokensResponseFactory = mockk() + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val handler = FetchWalletAccountsErrorHandler( + userTokensSaver = userTokensSaver, + userWalletsStore = userWalletsStore, + userTokensResponseStore = userTokensResponseStore, + cryptoPortfolioCF = cryptoPortfolioCF, + userTokensResponseFactory = userTokensResponseFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + @BeforeEach + fun setupEach() { + clearMocks( + userTokensSaver, + userWalletsStore, + userTokensResponseStore, + cryptoPortfolioCF, + cryptoPortfolioConverter, + cardCryptoCurrencyFactory, + ) + } + + @Test + fun `does not update accounts when response is up to date`() = runTest { + // Arrange + val error = ApiResponseError.HttpException( + code = Code.NOT_MODIFIED, + message = "Not Modified", + errorBody = null, + ) + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk() + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk() + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify(inverse = true) { + userWalletsStore.getSyncStrict(key = any()) + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) + cryptoPortfolioCF.create(any()) + cryptoPortfolioConverter.convertListBack(any()) + pushWalletAccounts(any(), any()) + userTokensSaver.push(userWalletId = any(), response = any()) + storeWalletAccounts(any(), any()) + } + } + + @Test + fun `pushes and stores accounts when NOT_FOUND error occurs`() = runTest { + // Arrange + val error = ApiResponseError.HttpException( + code = Code.NOT_FOUND, + message = "Not Found", + errorBody = null, + ) + + val accountDTO = WalletAccountDTO( + id = "nibh", + name = "Michael Dotson", + derivationIndex = 7135, + icon = "consectetuer", + iconColor = "ferri", + tokens = listOf(), + totalTokens = 7738, + totalNetworks = 3348, + ) + + val savedAccountsResponse = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(accountDTO), + unassignedTokens = emptyList(), + ) + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = savedAccountsResponse, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify { + pushWalletAccounts(userWalletId, listOf(accountDTO)) + userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + storeWalletAccounts(userWalletId, savedAccountsResponse) + } + + coVerify(inverse = true) { + userWalletsStore.getSyncStrict(key = any()) + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) + cryptoPortfolioCF.create(any()) + cryptoPortfolioConverter.convertListBack(any()) + } + } + + @Test + fun `uses default accounts when savedAccountsResponse is null`() = runTest { + // Arrange + val error = ApiResponseError.TimeoutException + + val accounts = AccountList.empty(userWallet).accounts + .filterIsInstance() + + val accountDTO = WalletAccountDTO( + id = "nibh", + name = "Michael Dotson", + derivationIndex = 7135, + icon = "consectetuer", + iconColor = "ferri", + tokens = listOf(), + totalTokens = 7738, + totalNetworks = 3348, + ) + + val savedAccountsResponse = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(accountDTO), + unassignedTokens = emptyList(), + ) + + val userTokensResponse = savedAccountsResponse.toUserTokensResponse() + + every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet + every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter + every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO) + coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns userTokensResponse + every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify { + userWalletsStore.getSyncStrict(userWalletId) + cryptoPortfolioCF.create(userWallet) + cryptoPortfolioConverter.convertListBack(accounts) + userTokensResponseStore.getSyncOrNull(userWalletId) + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) + storeWalletAccounts(userWalletId, any()) + } + + coVerify(inverse = true) { + pushWalletAccounts(any(), any()) + userTokensSaver.push(userWalletId = any(), response = any()) + } + } + + private companion object { + + val userWalletId = UserWalletId("011") + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt new file mode 100644 index 0000000000..8f9089305d --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt @@ -0,0 +1,324 @@ +package com.tangem.data.account.utils + +import com.google.common.truth.Truth +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetWalletAccountsResponseExtTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FlattenTokens { + + @Test + fun `flattenTokens returns empty list when accounts are empty`() { + // Arrange + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `flattenTokens returns empty list when single account has empty tokens`() { + // Arrange + val account = createWalletAccountDTO(derivationIndex = 0) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `flattenTokens returns all tokens from multiple accounts`() { + // Arrange + val token1 = createUserToken(id = "0") + val token2 = createUserToken(id = "1") + val token3 = createUserToken(id = "2") + + val account1 = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1, token2)) + val account2 = createWalletAccountDTO(derivationIndex = 1, tokens = listOf(token3)) + val account3 = createWalletAccountDTO(derivationIndex = 2) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account1, account2, account3), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).containsExactly(token1, token2, token3) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ToUserTokensResponse { + + @Test + fun `toUserTokensResponse returns correct UserTokensResponse for empty accounts and unassignedTokens`() { + // Arrange + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.toUserTokensResponse() + + // Assert + val expected = UserTokensResponse( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + tokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `toUserTokensResponse includes tokens from accounts and unassignedTokens`() { + // Arrange + val token1 = createUserToken(id = "0") + val token2 = createUserToken(id = "1") + val account = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1)) + val unassignedToken = token2 + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = listOf(unassignedToken), + ) + + // Act + val actual = response.toUserTokensResponse() + + // Assert + val expected = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = listOf(token1), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetWalletAccountsResponseAssignTokens { + + @Test + fun `assignTokens correctly assigns tokens to accounts`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = null) + val token2 = createUserToken(id = "1", accountId = null) + val account1 = createWalletAccountDTO(derivationIndex = 0) + val account2 = createWalletAccountDTO(derivationIndex = 1) + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account1, account2), + unassignedTokens = listOf(token1, token2), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + token2.copy(accountId = accountId), + ), + ), + account2, + ), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `assignTokens does not change accounts if there are no unassignedTokens`() { + // Arrange + val account = createWalletAccountDTO(derivationIndex = 0) + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class WalletAccountDTOListAssignTokens { + + @Test + fun `assignTokens correctly assigns tokens to accounts`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = null) + val token2 = createUserToken(id = "1", accountId = null) + val account1 = createWalletAccountDTO(derivationIndex = 0) + val account2 = createWalletAccountDTO(derivationIndex = 1) + + // Act + val actual = listOf(account1, account2).assignTokens( + userWalletId = userWalletId, + tokens = listOf(token1, token2), + ) + + // Assert + val expected = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + token2.copy(accountId = accountId), + ), + ), + account2, + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `assignTokens does not change accounts if there are no unassignedTokens`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = accountId) + val account1 = createWalletAccountDTO(derivationIndex = 0) + + // Act + val actual = listOf(account1).assignTokens( + userWalletId = userWalletId, + tokens = listOf(token1), + ) + + // Assert + val expected = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + ), + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + private fun createWalletAccountDTO(derivationIndex: Int, tokens: List = emptyList()) = + WalletAccountDTO( + id = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = DerivationIndex(derivationIndex).getOrNull()!!, + ).value, + name = "Name #$derivationIndex", + derivationIndex = derivationIndex, + icon = "icon", + iconColor = "color", + tokens = tokens, + totalTokens = tokens.size, + totalNetworks = 1, + ) + + private fun createUserToken(id: String, accountId: String? = "account_id") = UserTokensResponse.Token( + id = id, + accountId = accountId, + networkId = "ethereum", + derivationPath = "m/44'/60'/0'/0/0", + name = "Token", + symbol = "T", + contractAddress = "0x$id", + decimals = 18, + ) + + private companion object { + + val userWalletId = UserWalletId("011") + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt index b52d3c3f86..f91e9f40f3 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt @@ -24,23 +24,36 @@ object UserTokensResponseAccountIdEnricher { * @param response the [UserTokensResponse] containing tokens to be enriched */ operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse { - val hasUnassignedTokens = response.tokens.any { it.accountId == null } - if (!hasUnassignedTokens) return response + val enrichedTokens = invoke(userWalletId = userWalletId, tokens = response.tokens) - val enrichedTokens = response.tokens + return response.copy(tokens = enrichedTokens) + } + + /** + * Enriches the given list of tokens with accountId values + * + * @param userWalletId the ID of the user wallet + * @param tokens the list of tokens to be enriched + */ + operator fun invoke( + userWalletId: UserWalletId, + tokens: List, + ): List { + val hasUnassignedTokens = tokens.any { it.accountId == null } + if (!hasUnassignedTokens) return tokens + + val enrichedTokens = tokens .filter { it.accountId == null } .groupByAccountIndex() .mapKeysToAccountId(userWalletId) .mapToEnrichedTokens() - if (enrichedTokens.isEmpty()) return response + if (enrichedTokens.isEmpty()) return tokens - return response.copy( - tokens = response.tokens.map { token -> - val enrichedToken = enrichedTokens.find { it == token } - enrichedToken ?: token - }, - ) + return tokens.map { token -> + val enrichedToken = enrichedTokens.firstOrNull { it == token } + enrichedToken ?: token + } } private fun List.groupByAccountIndex(): Map> { From 551e2a60bbbf61569fdf4e1ceeccfb250a3d4893 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Sep 2025 10:04:56 +0200 Subject: [PATCH 061/132] Updated on 2026-08-14 --- .../tap/di/domain/OnrampDomainModule.kt | 2 + core/res/src/main/res/values/strings.xml | 1 + .../src/main/res/drawable/ic_best_rate_12.xml | 16 + .../main/res/drawable/ic_staking_mini_10.xml | 9 + .../onramp/GetOnrampAllOffersUseCase.kt | 6 +- .../domain/onramp/GetOnrampOffersUseCase.kt | 48 +-- .../domain/onramp/utils/OnrampOfferUtils.kt | 26 ++ .../onramp/GetOnrampOffersUseCaseTest.kt | 8 + .../onramp/alloffers/AllOffersComponent.kt | 3 + .../alloffers/DefaultAllOffersComponent.kt | 9 +- .../alloffers/entity/AllOffersIntents.kt | 14 + .../alloffers/entity/AllOffersStateFactory.kt | 154 +++++++++ .../alloffers/entity/AllOffersStateUM.kt | 34 ++ .../onramp/alloffers/model/AllOffersModel.kt | 80 ++++- .../alloffers/ui/AllOffersContentSheet.kt | 322 ++++++++++++++++++ .../alloffers/ui/PaymentMethodsContent.kt | 262 ++++++++++++++ .../mainv2/DefaultOnrampV2MainComponent.kt | 4 +- .../di/OnrampMainV2ComponentModelModule.kt | 2 +- .../entity/OnrampV2MainBottomSheetConfig.kt | 2 +- .../OnrampV2AmountFieldChangeConverter.kt | 1 + .../model/OnrampV2MainComponentModel.kt | 6 +- .../onramp/mainv2/ui/OnrampOffersContent.kt | 13 +- 22 files changed, 985 insertions(+), 37 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_best_rate_12.xml create mode 100644 core/ui/src/main/res/drawable/ic_staking_mini_10.xml create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index 18f3540843..a0e87e1ab1 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -254,11 +254,13 @@ internal object OnrampDomainModule { onrampRepository: OnrampRepository, onrampErrorResolver: OnrampErrorResolver, onrampTransactionRepository: OnrampTransactionRepository, + settingsRepository: SettingsRepository, ): GetOnrampOffersUseCase { return GetOnrampOffersUseCase( onrampRepository = onrampRepository, errorResolver = onrampErrorResolver, onrampTransactionRepository = onrampTransactionRepository, + settingsRepository = settingsRepository, ) } } \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index b475fe441f..c3d240ace5 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -898,6 +898,7 @@ %s min You get You can close this screen and check the transaction status on the token details screen. + Up to Via You will pay Group diff --git a/core/ui/src/main/res/drawable/ic_best_rate_12.xml b/core/ui/src/main/res/drawable/ic_best_rate_12.xml new file mode 100644 index 0000000000..99e0e500c2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_best_rate_12.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_staking_mini_10.xml b/core/ui/src/main/res/drawable/ic_staking_mini_10.xml new file mode 100644 index 0000000000..69aab19b6c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_staking_mini_10.xml @@ -0,0 +1,9 @@ + + + diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt index f2f787e182..a7e1d2754f 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt @@ -13,6 +13,7 @@ import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.onramp.utils.calculateRateDif +import com.tangem.domain.onramp.utils.compareOffersByRateSpeedAndPriority import com.tangem.domain.settings.repositories.SettingsRepository import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -38,7 +39,7 @@ class GetOnrampAllOffersUseCase( if (validQuotes.isEmpty()) return emptyList() val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() - val overallBestRateQuote = validQuotes.maxByOrNull { it.toAmount.value } + val overallBestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) val bestRate = overallBestRateQuote?.toAmount?.value val offersByPaymentMethod = validQuotes.groupBy { it.paymentMethod } @@ -54,7 +55,8 @@ class GetOnrampAllOffersUseCase( OnrampOffer(quote = quote, rateDif = rateDif, advantages = advantages) } - val groupBestRateOfferData = methodQuotes.maxByOrNull { it.toAmount.value } + val groupBestRateOfferData = + methodQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) val groupBestRateOffer = methodOffers.find { when (val quote = it.quote) { is OnrampQuote.Data -> quote == groupBestRateOfferData diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt index 1632abd3cc..81ee5e82fd 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt @@ -12,15 +12,17 @@ import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.onramp.repositories.OnrampTransactionRepository import com.tangem.domain.onramp.utils.calculateRateDif +import com.tangem.domain.onramp.utils.compareOffersByRateSpeedAndPriority +import com.tangem.domain.settings.repositories.SettingsRepository import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -import java.math.BigDecimal class GetOnrampOffersUseCase( private val onrampRepository: OnrampRepository, private val onrampTransactionRepository: OnrampTransactionRepository, private val errorResolver: OnrampErrorResolver, + private val settingsRepository: SettingsRepository, ) { operator fun invoke( @@ -37,14 +39,15 @@ class GetOnrampOffersUseCase( .catch { throwable -> errorResolver.resolve(throwable).left() } } - private fun processOffers( + private suspend fun processOffers( quotes: List, transactions: List, ): List { val validQuotes = quotes.filterIsInstance() if (validQuotes.isEmpty()) return emptyList() - val bestRateQuote = validQuotes.maxByOrNull { it.toAmount.value } + val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() + val bestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) val bestRate = bestRateQuote?.toAmount?.value val offers = validQuotes.map { quote -> @@ -53,8 +56,8 @@ class GetOnrampOffersUseCase( } val recentOffer = findRecentOffer(offers, transactions) - val bestRateOffer = findBestRateOffer(offers) - val fastestOffer = findFastestOffer(offers) + val bestRateOffer = findBestRateOffer(offers, isGooglePayAvailable) + val fastestOffer = findFastestOffer(offers, isGooglePayAvailable) return buildOffersBlocks( recentOffer = recentOffer, @@ -73,36 +76,35 @@ class GetOnrampOffersUseCase( } } - private fun findBestRateOffer(offers: List): OnrampOffer? { - return offers.maxByOrNull { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> quote.toAmount.value - else -> BigDecimal.ZERO - } - } + private fun findBestRateOffer(offers: List, isGooglePayAvailable: Boolean): OnrampOffer? { + return offers.maxWithOrNull(offerComparator(isGooglePayAvailable)) } - private fun findFastestOffer(offers: List): OnrampOffer? { + private fun findFastestOffer(offers: List, isGooglePayAvailable: Boolean): OnrampOffer? { val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() } return if (instantOffers.isNotEmpty()) { - instantOffers.maxByOrNull { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> quote.toAmount.value - else -> BigDecimal.ZERO - } - } + instantOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) } else { val offersBySpeed = offers.groupBy { offer -> offer.quote.paymentMethod.type.getProcessingSpeed().speed } val fastestSpeed = offersBySpeed.keys.minOrNull() ?: return null val fastestOffers = offersBySpeed[fastestSpeed] ?: return null - fastestOffers.maxByOrNull { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> quote.toAmount.value - else -> BigDecimal.ZERO + fastestOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + } + } + + private fun offerComparator(isGooglePayAvailable: Boolean): Comparator = Comparator { offer1, offer2 -> + when (val quote1 = offer1.quote) { + is OnrampQuote.Data -> { + when (val quote2 = offer2.quote) { + is OnrampQuote.Data -> { + compareOffersByRateSpeedAndPriority(isGooglePayAvailable).compare(quote1, quote2) + } + else -> 1 } } + else -> -1 } } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt index 0b3625d494..7329a88a84 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt @@ -1,8 +1,34 @@ package com.tangem.domain.onramp.utils +import com.tangem.domain.onramp.model.OnrampQuote import java.math.BigDecimal internal fun calculateRateDif(currentTokenRate: BigDecimal, bestRate: BigDecimal?): BigDecimal? { if (bestRate == null) return null return BigDecimal.ONE - currentTokenRate / bestRate +} + +internal fun compareOffersByRateSpeedAndPriority(isGooglePayAvailable: Boolean): Comparator { + return Comparator { quote1, quote2 -> + val rateComparison = quote1 + .toAmount + .value + .compareTo(quote2.toAmount.value) + if (rateComparison != 0) return@Comparator rateComparison + + val speedComparison = + quote2 + .paymentMethod + .type + .getProcessingSpeed() + .speed + .compareTo(quote1.paymentMethod.type.getProcessingSpeed().speed) + if (speedComparison != 0) return@Comparator speedComparison + + quote1 + .paymentMethod + .type + .getPriority(isGooglePayAvailable) + .compareTo(quote2.paymentMethod.type.getPriority(isGooglePayAvailable)) + } } \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt index 62b55b230a..87f9fbb1c6 100644 --- a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt @@ -8,6 +8,7 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.onramp.repositories.OnrampTransactionRepository +import com.tangem.domain.settings.repositories.SettingsRepository import io.mockk.* import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -22,6 +23,7 @@ class GetOnrampOffersUseCaseTest { private val onrampRepository: OnrampRepository = mockk(relaxUnitFun = true) private val onrampTransactionRepository: OnrampTransactionRepository = mockk(relaxUnitFun = true) private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true) + private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true) private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true) private val userWalletId: UserWalletId = mockk(relaxUnitFun = true) @@ -34,6 +36,7 @@ class GetOnrampOffersUseCaseTest { onrampRepository = onrampRepository, onrampTransactionRepository = onrampTransactionRepository, errorResolver = errorResolver, + settingsRepository = settingsRepository, ) } @@ -42,6 +45,7 @@ class GetOnrampOffersUseCaseTest { val emptyQuotes = listOf() val emptyTransactions = listOf() + coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes) coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( emptyTransactions, @@ -77,6 +81,7 @@ class GetOnrampOffersUseCaseTest { createMockTransaction("provider1", "card", 1000L), ) + coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( transactions, @@ -121,6 +126,7 @@ class GetOnrampOffersUseCaseTest { val transactions = emptyList() + coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( transactions, @@ -165,6 +171,7 @@ class GetOnrampOffersUseCaseTest { val transactions = emptyList() + coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( transactions, @@ -218,6 +225,7 @@ class GetOnrampOffersUseCaseTest { val transactions = emptyList() + coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( transactions, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt index 7c2de6da52..d2d2615c0e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampProviderWithQuote internal interface AllOffersComponent : ComposableBottomSheetComponent { @@ -11,6 +12,8 @@ internal interface AllOffersComponent : ComposableBottomSheetComponent { val userWallet: UserWallet, val cryptoCurrency: CryptoCurrency, val onDismiss: () -> Unit, + val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + val amountCurrencyCode: String, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt index 02adaaa504..12c4ec31f9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt @@ -1,9 +1,12 @@ package com.tangem.features.onramp.alloffers import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.onramp.alloffers.model.AllOffersModel +import com.tangem.features.onramp.alloffers.ui.AllOffersContentSheet import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -21,7 +24,11 @@ internal class DefaultAllOffersComponent @AssistedInject constructor( @Composable override fun BottomSheet() { - // TODO to be continued in [REDACTED_TASK_KEY] + val state by model.state.collectAsState() + AllOffersContentSheet( + state = state, + onCloseClick = { dismiss() }, + ) } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt new file mode 100644 index 0000000000..4d2bbcec90 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt @@ -0,0 +1,14 @@ +package com.tangem.features.onramp.alloffers.entity + +import com.tangem.domain.onramp.model.OnrampProviderWithQuote + +interface AllOffersIntents { + + fun onPaymentMethodClicked(paymentMethodId: String) + + fun onBuyClick(quote: OnrampProviderWithQuote.Data) + + fun onBackClicked() + + fun onRefresh() +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt new file mode 100644 index 0000000000..67878d0738 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt @@ -0,0 +1,154 @@ +package com.tangem.features.onramp.alloffers.entity + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.onramp.model.* +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns.MINUS +import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal + +internal class AllOffersStateFactory( + private val currentStateProvider: Provider, + private val allOffersIntents: AllOffersIntents, +) { + + fun getLoadedPaymentsState(methodGroups: List): AllOffersStateUM { + return AllOffersStateUM.Content( + methods = methodGroups.map { methodGroup -> + AllOffersPaymentMethodUM( + offers = mapOffersToUM(methodGroup.offers).toPersistentList(), + methodConfig = OnrampPaymentMethodConfig( + method = methodGroup.paymentMethod, + onClick = { allOffersIntents.onPaymentMethodClicked(methodGroup.paymentMethod.id) }, + ), + diff = methodGroup + .bestRateOffer + ?.rateDif + ?.takeIf { it > BigDecimal.ZERO } + ?.let { diff -> + stringReference("$MINUS${diff.format { percent() }}") + }, + rate = methodGroup.bestRateOffer?.let { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value.format { + crypto( + symbol = quote.toAmount.symbol, + decimals = quote.toAmount.decimals, + ) + } + else -> "" + } + } ?: "", + providersCount = methodGroup.providerCount, + isBestRate = methodGroup.isBestPaymentMethod, + ) + }.toPersistentList(), + currentMethod = null, + onBackClicked = { allOffersIntents.onBackClicked() }, + ) + } + + fun getPaymentsState(): AllOffersStateUM { + return when (val currentState = currentStateProvider.invoke()) { + is AllOffersStateUM.Content -> { + currentState.copy(currentMethod = null) + } + AllOffersStateUM.Loading, + is AllOffersStateUM.Error, + -> currentState + } + } + + fun getOnrampErrorState(onrampError: OnrampError): AllOffersStateUM { + return when (onrampError) { + is OnrampError.DataError -> getErrorState( + errorCode = onrampError.code, + onRefresh = allOffersIntents::onRefresh, + ) + OnrampError.PairsNotFound, + is OnrampError.DomainError, + -> getErrorState(onRefresh = allOffersIntents::onRefresh) + is OnrampError.AmountError.TooBigError, + is OnrampError.AmountError.TooSmallError, + OnrampError.RedirectError.VerificationFailed, + OnrampError.RedirectError.WrongRequestId, + -> currentStateProvider() + } + } + + private fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): AllOffersStateUM { + val state = currentStateProvider() + return when (state) { + is AllOffersStateUM.Content, + AllOffersStateUM.Loading, + -> AllOffersStateUM.Error( + errorNotification = NotificationUM.Warning.OnrampErrorNotification( + errorCode = errorCode, + onRefresh = onRefresh, + ), + ) + is AllOffersStateUM.Error -> state + } + } + + private fun mapOfferAdvantagesDTOtoUM(advantages: OnrampOfferAdvantages): OnrampOfferAdvantagesUM { + return when (advantages) { + OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default + OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate + OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest + } + } + + private fun mapOffersToUM(offers: List): List { + return buildList { + offers.forEach { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> { + add( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), + paymentMethod = quote.paymentMethod, + providerId = quote.provider.id, + providerName = quote.provider.info.name, + rate = quote.toAmount.value.format { + crypto( + symbol = quote.toAmount.symbol, + decimals = quote.toAmount.decimals, + ) + }, + diff = offer + .rateDif + ?.takeIf { it > BigDecimal.ZERO } + ?.let { diff -> + stringReference("$MINUS${diff.format { percent() }}") + }, + onBuyClicked = { + allOffersIntents.onBuyClick( + OnrampProviderWithQuote.Data( + provider = quote.provider, + paymentMethod = quote.paymentMethod, + toAmount = quote.toAmount, + fromAmount = quote.fromAmount, + ), + ) + }, + ), + ) + } + is OnrampQuote.AmountError, + is OnrampQuote.Error, + -> Unit + } + } + } + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt new file mode 100644 index 0000000000..834bc396b3 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt @@ -0,0 +1,34 @@ +package com.tangem.features.onramp.alloffers.entity + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import kotlinx.collections.immutable.ImmutableList + +internal sealed interface AllOffersStateUM { + + data object Loading : AllOffersStateUM + + data class Content( + val methods: ImmutableList, + val currentMethod: AllOffersPaymentMethodUM? = null, + val onBackClicked: () -> Unit, + ) : AllOffersStateUM + + data class Error(val errorNotification: NotificationUM) : AllOffersStateUM +} + +internal data class AllOffersPaymentMethodUM( + val offers: ImmutableList, + val methodConfig: OnrampPaymentMethodConfig, + val diff: TextReference?, + val rate: String, + val providersCount: Int, + val isBestRate: Boolean, +) + +internal data class OnrampPaymentMethodConfig( + val method: OnrampPaymentMethod, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt index 3280c17cb9..19e69d6696 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -1,19 +1,97 @@ package com.tangem.features.onramp.alloffers.model +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.onramp.GetOnrampAllOffersUseCase +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.features.onramp.alloffers.AllOffersComponent +import com.tangem.features.onramp.alloffers.entity.AllOffersIntents +import com.tangem.features.onramp.alloffers.entity.AllOffersStateFactory +import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM +import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject internal class AllOffersModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val getOnrampAllOffersUseCase: GetOnrampAllOffersUseCase, paramsContainer: ParamsContainer, -) : Model() { +) : Model(), AllOffersIntents { + + private var quotesJob: Job? = null + + private val stateFactory: AllOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) { + AllOffersStateFactory( + currentStateProvider = Provider { state.value }, + allOffersIntents = this, + ) + } private val params: AllOffersComponent.Params = paramsContainer.require() + private val _state: MutableStateFlow = MutableStateFlow(AllOffersStateUM.Loading) + val state: StateFlow = _state.asStateFlow() + + init { + subscribeOnAllOffers() + } + fun dismiss() { params.onDismiss() } + + override fun onPaymentMethodClicked(paymentMethodId: String) { + val contentState = state.value as? AllOffersStateUM.Content ?: return + val method = contentState.methods.firstOrNull { it.methodConfig.method.id == paymentMethodId } ?: return + _state.update { contentState.copy(currentMethod = method) } + } + + override fun onBuyClick(quote: OnrampProviderWithQuote.Data) { + analyticsEventHandler.send( + OnrampAnalyticsEvent.OnBuyClick( + providerName = quote.provider.info.name, + currency = params.amountCurrencyCode, + tokenSymbol = params.cryptoCurrency.symbol, + ), + ) + params.openRedirectPage(quote) + } + + override fun onBackClicked() { + _state.update { stateFactory.getPaymentsState() } + } + + override fun onRefresh() { + subscribeOnAllOffers() + } + + private fun subscribeOnAllOffers() { + quotesJob?.cancel() + quotesJob = modelScope.launch(dispatchers.default) { + getOnrampAllOffersUseCase.invoke( + userWalletId = params.userWallet.walletId, + cryptoCurrencyId = params.cryptoCurrency.id, + ).collectLatest { maybeOffers -> + maybeOffers.fold( + ifLeft = ::handleOnrampError, + ifRight = { offersGroup -> + _state.update { stateFactory.getLoadedPaymentsState(offersGroup) } + }, + ) + } + } + } + + private fun handleOnrampError(onrampError: OnrampError) { + Timber.e(onrampError.toString()) + _state.update { stateFactory.getOnrampErrorState(onrampError) } + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt new file mode 100644 index 0000000000..f60660546a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt @@ -0,0 +1,322 @@ +package com.tangem.features.onramp.alloffers.ui + +import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM +import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM +import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import com.tangem.features.onramp.mainv2.ui.Offer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () -> Unit) { + val onBack = remember(state) { + { + if (state is AllOffersStateUM.Content && state.currentMethod != null) { + state.onBackClicked() + } else { + onCloseClick() + } + } + } + + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onCloseClick, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onBack, + containerColor = TangemTheme.colors.background.tertiary, + title = { + if (state is AllOffersStateUM.Content && state.currentMethod != null) { + ProviderTitle( + onCloseClick = onCloseClick, + onBackClick = onBack, + ) + } else { + PaymentMethodTitle(onCloseClick = onCloseClick) + } + }, + content = { + Box( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp) + .animateContentSize(), + ) { + AnimatedContent( + targetState = state is AllOffersStateUM.Content && state.currentMethod != null, + transitionSpec = { + fadeIn(tween(durationMillis = 220)) togetherWith + fadeOut(tween(durationMillis = 220)) + }, + label = "Change offers and payment method state", + ) { shouldShowOffersScreen -> + when (state) { + AllOffersStateUM.Loading -> AllOffersContentLoading() + is AllOffersStateUM.Error -> AllOffersError(state.errorNotification) + is AllOffersStateUM.Content -> { + if (shouldShowOffersScreen) { + state.currentMethod?.let { + OffersBasedOnPaymentMethodContent(offers = it.offers) + } + } else { + PaymentMethodsContent(methods = state.methods) + } + } + } + } + } + }, + ) +} + +@Composable +private fun ProviderTitle(onBackClick: () -> Unit, onCloseClick: () -> Unit) { + TangemModalBottomSheetTitle( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + subtitle = TextReference.Res(R.string.express_choose_providers_title), + startIconRes = R.drawable.ic_back_24, + onStartClick = onBackClick, + endIconRes = com.tangem.core.ui.R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) +} + +@Composable +private fun PaymentMethodTitle(onCloseClick: () -> Unit) { + TangemModalBottomSheetTitle( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + subtitle = TextReference.Res(R.string.onramp_payment_method_subtitle), + endIconRes = com.tangem.core.ui.R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) +} + +@Composable +private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + offers.fastForEach { offer -> + key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") { + Offer(offer) + SpacerH(8.dp) + } + } + } +} + +@Composable +private fun AllOffersContentLoading() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.tertiary) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RectangleShimmer( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .height(96.dp) + .size(width = 76.dp, height = 20.dp), + radius = 14.dp, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .height(96.dp) + .size(width = 76.dp, height = 20.dp), + radius = 14.dp, + ) + RectangleShimmer( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .height(96.dp) + .size(width = 76.dp, height = 20.dp), + radius = 14.dp, + ) + } +} + +@Composable +fun AllOffersError(errorNotification: NotificationUM) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Notification(config = errorNotification.config) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AllOffersContentSheetPaymentPreview() { + val method = AllOffersPaymentMethodUM( + offers = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + methodConfig = OnrampPaymentMethodConfig( + method = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + onClick = {}, + ), + diff = null, + rate = "0,0245334 BTC", + providersCount = 2, + isBestRate = true, + ) + + TangemThemePreview { + AllOffersContentSheet( + state = AllOffersStateUM.Content( + methods = persistentListOf(), + currentMethod = method, + onBackClicked = {}, + ), + onCloseClick = {}, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AllOffersContentSheetOffersPreview() { + val methods = List(5) { + AllOffersPaymentMethodUM( + offers = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = + "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = + "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + methodConfig = OnrampPaymentMethodConfig( + method = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + onClick = {}, + ), + diff = null, + rate = "0,0245334 BTC", + providersCount = 2, + isBestRate = true, + ) + } + + TangemThemePreview { + AllOffersContentSheet( + state = AllOffersStateUM.Content( + methods = methods.toPersistentList(), + currentMethod = null, + onBackClicked = {}, + ), + onCloseClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt new file mode 100644 index 0000000000..49d457c4b2 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt @@ -0,0 +1,262 @@ +package com.tangem.features.onramp.alloffers.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM +import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM +import com.tangem.features.onramp.mainv2.ui.TimingBlock +import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PaymentMethodsContent(methods: ImmutableList) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + methods.fastForEach { method -> + key(method.methodConfig.method.id) { + PaymentMethod(methodUM = method) + SpacerH(8.dp) + } + } + } +} + +@Composable +private fun PaymentMethod(methodUM: AllOffersPaymentMethodUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = RoundedCornerShape(14.dp), + ) + .clickable( + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = methodUM.methodConfig.onClick, + ) + .padding( + start = 12.dp, + end = 12.dp, + top = 14.dp, + bottom = 12.dp, + ), + ) { + PaymentMethodIcon( + modifier = Modifier.size(36.dp), + imageUrl = methodUM.methodConfig.method.imageUrl, + ) + + SpacerW(12.dp) + + Column { + PaymentMethodInfoBlock( + paymentMethodName = methodUM.methodConfig.method.name, + rate = methodUM.rate, + diff = methodUM.diff, + isBestRate = methodUM.isBestRate, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + ProvidersCountBlockInfo(providersCount = methodUM.providersCount) + SpacerW(8.dp) + TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed()) + } + } + } +} + +@Composable +private fun PaymentMethodInfoBlock( + paymentMethodName: String, + rate: String, + diff: TextReference?, + isBestRate: Boolean, +) { + Column(modifier = Modifier.padding(bottom = 14.dp)) { + Text( + text = paymentMethodName, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + SpacerH(2.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResourceSafe(R.string.onramp_up_to_rate), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + + Text( + text = rate, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + + when { + isBestRate -> { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_best_rate_12), + contentDescription = null, + ) + } + diff != null -> { + Text( + modifier = Modifier + .background( + color = TangemTheme.colors.text.warning.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 4.dp), + text = diff.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.warning, + ) + } + } + } + } +} + +@Composable +private fun ProvidersCountBlockInfo(providersCount: Int) { + BorderedRow { + Icon( + modifier = Modifier.size(10.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + SpacerW(4.dp) + Text( + text = pluralStringResourceSafe( + id = R.plurals.onramp_providers_count, + count = providersCount, + providersCount, + ), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +private fun TimingBlockInfo(speed: PaymentMethodType.PaymentSpeed) { + BorderedRow { + Icon( + modifier = Modifier.size(10.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_staking_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + SpacerW(4.dp) + TimingBlock(speed) + } +} + +@Composable +fun BorderedRow(content: @Composable RowScope.() -> Unit) { + Row( + modifier = Modifier + .border( + width = 1.dp, + color = TangemTheme.colors.stroke.primary, + shape = RoundedCornerShape(6.dp), + ) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +@Preview +@Composable +private fun PaymentMethodsContentPreview() { + val method = AllOffersPaymentMethodUM( + offers = persistentListOf( + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.BestRate, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId1", + providerName = "Simplex", + rate = "0,0245334 BTC", + diff = null, + onBuyClicked = {}, + ), + OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Default, + paymentMethod = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + providerId = "providerId2", + providerName = "Simplex", + rate = "0,00145334 BTC", + diff = stringReference("–0.07%"), + onBuyClicked = {}, + ), + ), + methodConfig = OnrampPaymentMethodConfig( + method = OnrampPaymentMethod( + id = "card", + name = "Card", + imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", + type = PaymentMethodType.CARD, + ), + onClick = {}, + ), + diff = null, + rate = "0,0245334 BTC", + providersCount = 2, + isBestRate = true, + ) + TangemThemePreview { + PaymentMethodsContent(persistentListOf(method)) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt index 2487bf37b1..af62d7ecbe 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt @@ -69,12 +69,14 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor( onDismiss = model.bottomSheetNavigation::dismiss, ), ) - OnrampV2MainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create( + is OnrampV2MainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create( context = childByContext(componentContext), params = AllOffersComponent.Params( userWallet = model.userWallet, cryptoCurrency = params.cryptoCurrency, onDismiss = model.bottomSheetNavigation::dismiss, + openRedirectPage = params.openRedirectPage, + amountCurrencyCode = config.amountCurrencyCode, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt index 6d20f87cf7..84fb039ffd 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt @@ -16,5 +16,5 @@ internal interface OnrampMainV2ComponentModelModule { @Binds @IntoMap @ClassKey(OnrampV2MainComponentModel::class) - fun bindOnrampSelectCountryModel(model: OnrampV2MainComponentModel): Model + fun bindOnrampV2MainComponentModel(model: OnrampV2MainComponentModel): Model } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt index d9e6c47a2d..afc654a422 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt @@ -12,5 +12,5 @@ sealed interface OnrampV2MainBottomSheetConfig { data object CurrenciesList : OnrampV2MainBottomSheetConfig @Serializable - data object AllOffers : OnrampV2MainBottomSheetConfig + data class AllOffers(val amountCurrencyCode: String) : OnrampV2MainBottomSheetConfig } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt index 2f8f0f4186..e0947e34b5 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt @@ -45,6 +45,7 @@ internal class OnrampV2AmountFieldChangeConverter( continueButtonConfig = state.continueButtonConfig.copy(enabled = false), onrampProviderState = OnrampV2ProvidersUM.Loading, onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + offersBlockState = OnrampOffersBlockUM.Empty, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt index d83a94d1f0..abead667d7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -118,7 +118,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( } override fun openSettings() { - params.openSettings + params.openSettings.invoke() } override fun openCurrenciesList() { @@ -139,7 +139,9 @@ internal class OnrampV2MainComponentModel @Inject constructor( } override fun openProviders() { - bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.AllOffers) + val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return + val amountCurrentCode = currentContentState.amountBlockState.currencyUM.code + bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.AllOffers(amountCurrentCode)) } override fun onRefresh() { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt index 1fc602096c..05645f62d0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache @@ -80,9 +81,11 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { SpacerH(8.dp) - state.recommended.fastForEach { - Offer(it) - SpacerH(8.dp) + state.recommended.fastForEach { offer -> + key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") { + Offer(offer) + SpacerH(8.dp) + } } state.onrampAllOffersButtonConfig?.let { @@ -99,7 +102,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { } @Composable -private fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) { +internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxWidth() @@ -263,7 +266,7 @@ private fun PaymentBlockInOffer(paymentMethod: OnrampPaymentMethod, providerName } @Composable -private fun TimingBlock(speed: PaymentMethodType.PaymentSpeed) { +internal fun TimingBlock(speed: PaymentMethodType.PaymentSpeed) { val timingText = when (speed) { PaymentMethodType.PaymentSpeed.Instant -> { stringResourceSafe(id = R.string.onramp_instant_status) From 700357c26b518fb31fb23750715bc6617ab1ec81 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Sep 2025 15:46:25 +0300 Subject: [PATCH 062/132] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 6 + .../tangem/common/constants/TestConstants.kt | 2 + .../{steps => scenarios}/BaseScenarios.kt | 20 +- .../tangem/scenarios/DeepLinksScenarios.kt | 26 +++ .../com/tangem/scenarios/DialogScenarios.kt | 58 ++++++ .../scenarios/OpenMainScreenScenario.kt | 41 ---- .../WalletConnectScenarios.kt | 2 +- .../AlreadyUsedWalletDialogPageObject.kt | 32 ++++ .../com/tangem/screens/DialogPageObject.kt | 4 +- .../FailedTransactionDialogPageObject.kt | 44 +++++ .../tangem/screens/MainScreenPageObject.kt | 2 +- .../screens/ScanWarningDialogPageObject.kt | 36 ++++ .../tangem/screens/SendAddressPageObject.kt | 30 +++ .../tangem/screens/SendConfirmPageObject.kt | 30 +++ ...ingSendPageObject.kt => SendPageObject.kt} | 30 +-- ...eObject.kt => StakingConfirmPageObject.kt} | 11 +- .../tangem/screens/TokenDetailsPageObject.kt | 6 + .../com/tangem/steps/DeepLinksScenarios.kt | 15 -- .../kotlin/com/tangem/tests/BuyTokenTest.kt | 57 ++---- .../kotlin/com/tangem/tests/DetailsTest.kt | 18 +- .../kotlin/com/tangem/tests/FeedbackTest.kt | 176 ++++++++++++++++++ .../kotlin/com/tangem/tests/HideTokenTest.kt | 12 +- .../kotlin/com/tangem/tests/MainScreenTest.kt | 6 +- .../com/tangem/tests/OrganizeTokensTest.kt | 29 ++- .../kotlin/com/tangem/tests/StakingTest.kt | 125 +++++++------ .../kotlin/com/tangem/tests/SwapTokenTest.kt | 32 ++-- .../com/tangem/tests/WalletConnectTest.kt | 5 +- .../common/ui/amountScreen/ui/AmountBlock.kt | 6 +- .../ui/amountScreen/ui/AmountButtons.kt | 10 +- .../common/ui/amountScreen/ui/AmountField.kt | 4 +- .../amountScreen/ui/AmountFieldContainer.kt | 6 +- .../NavigationButtonsBlock.kt | 4 +- .../com/tangem/core/ui/components/Dialogs.kt | 10 +- .../ui/components/fields/AmountTextField.kt | 4 +- .../components/inputrow/InputRowRecipient.kt | 6 +- .../core/ui/test/BaseAmountBlockTestTags.kt | 6 + .../tangem/core/ui/test/BaseDialogTestTags.kt | 7 + .../com/tangem/core/ui/test/DialogTestTags.kt | 5 - .../core/ui/test/SendAddressScreenTestTags.kt | 6 + .../tangem/core/ui/test/SendScreenTestTags.kt | 16 ++ .../test/StakingSendDetailsScreenTestTags.kt | 8 +- .../core/ui/test/StakingSendScreenTestTags.kt | 16 -- .../impl/presentation/ui/StakingScreen.kt | 4 +- tangem-android-tools | 2 +- 44 files changed, 683 insertions(+), 292 deletions(-) rename app/src/androidTest/kotlin/com/tangem/{steps => scenarios}/BaseScenarios.kt (70%) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt delete mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt rename app/src/androidTest/kotlin/com/tangem/{steps => scenarios}/WalletConnectScenarios.kt (99%) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/FailedTransactionDialogPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/ScanWarningDialogPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt rename app/src/androidTest/kotlin/com/tangem/screens/{StakingSendPageObject.kt => SendPageObject.kt} (61%) rename app/src/androidTest/kotlin/com/tangem/screens/{StakingSendDetailsPageObject.kt => StakingConfirmPageObject.kt} (75%) delete mode 100644 app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/BaseDialogTestTags.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/DialogTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/StakingSendScreenTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 7ab38e88e2..ae8dab772f 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -19,6 +19,8 @@ import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.models.PromoId import com.tangem.tap.MainActivity import dagger.hilt.android.testing.HiltAndroidRule import kotlinx.coroutines.runBlocking @@ -51,6 +53,9 @@ abstract class BaseTestCase : TestCase( @Inject lateinit var featureTogglesManager: FeatureTogglesManager + @Inject + lateinit var promoRepository: PromoRepository + private val hiltRule = HiltAndroidRule(this) private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( @@ -91,6 +96,7 @@ abstract class BaseTestCase : TestCase( value = false ) } + promoRepository.setNeverToShowWalletPromo(PromoId.Sepa) } apiEnvironmentRule.setup(apiConfigsManager) ActivityScenario.launch(MainActivity::class.java) diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index 00cfc3afe3..a985597ee8 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -3,5 +3,7 @@ package com.tangem.common.constants object TestConstants { const val TOTAL_BALANCE = "$3,299.18" + const val RECIPIENT_ADDRESS = "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0" + const val WAIT_UNTIL_TIMEOUT = 20_000L } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/steps/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt similarity index 70% rename from app/src/androidTest/kotlin/com/tangem/steps/BaseScenarios.kt rename to app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 54f4f8a915..8355c58267 100644 --- a/app/src/androidTest/kotlin/com/tangem/steps/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -1,29 +1,31 @@ -package com.tangem.steps +package com.tangem.scenarios import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType -import com.tangem.screens.onDisclaimerScreen -import com.tangem.screens.onMainScreen -import com.tangem.screens.onMarketsTooltipScreen -import com.tangem.screens.onStoriesScreen +import com.tangem.screens.* import com.tangem.tap.domain.sdk.mocks.MockProvider import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.openMainScreen(productType: ProductType? = null) { +fun BaseTestCase.openMainScreen(productType: ProductType? = null, alreadyActivatedDialogIsShown : Boolean = false) { if (productType != null) { MockProvider.setMocks(productType) } step("Click on 'Accept' button") { onDisclaimerScreen { acceptButton.clickWithAssertion() } } - step("Click on 'Accept' button") { + step("Click on 'Scan' button") { onStoriesScreen { scanButton.clickWithAssertion() } } - step("Assert main screen is displayed") { + if (alreadyActivatedDialogIsShown) { + step("Click on 'This is my wallet' button") { + AlreadyUsedWalletDialogPageObject { thisIsMyWalletButton.click() } + } + } + step("Assert 'Main' screen is displayed") { onMainScreen { screenContainer.assertIsDisplayed() } } - step("Assert main screen is displayed") { + step("Click on 'Market Tooltip' screen") { onMarketsTooltipScreen { contentContainer.clickWithAssertion() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt new file mode 100644 index 0000000000..e141a7cc1c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt @@ -0,0 +1,26 @@ +package com.tangem.scenarios + +import android.content.Intent +import android.content.Intent.* +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import io.github.kakaocup.kakao.intent.KIntent + +fun openAppByDeepLink(deepLinkUri: String?) { + val deeplinkScheme = "tangem://wc?uri=" + val context = ApplicationProvider.getApplicationContext() + val intent = Intent(ACTION_VIEW, Uri.parse(deeplinkScheme + deepLinkUri)).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) +} + +fun checkSendEMailIntentCalled() { + val expectedIntent = KIntent { + hasAction(ACTION_CHOOSER) + hasExtra(EXTRA_TITLE, "Send mail...") + hasExtraWithKey(EXTRA_INTENT) + } + expectedIntent.intended() +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt new file mode 100644 index 0000000000..0b7990da23 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt @@ -0,0 +1,58 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.AlreadyUsedWalletDialogPageObject +import com.tangem.screens.ScanWarningDialogPageObject +import com.tangem.screens.onFailedTransactionDialog +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkFailedTransactionDialog() { + step("Assert failed transaction dialog title is displayed") { + onFailedTransactionDialog { title.assertIsDisplayed() } + } + step("Assert failed transaction dialog text is displayed") { + onFailedTransactionDialog { text.assertIsDisplayed() } + } + step("Assert 'Cancel' button is displayed") { + onFailedTransactionDialog { cancelButton.assertIsDisplayed() } + } + step("Assert 'Support' button is displayed") { + onFailedTransactionDialog { supportButton.assertIsDisplayed() } + } +} + +fun checkScanWarningDialog() { + step("Assert 'Scan warning' dialog title is displayed") { + ScanWarningDialogPageObject { warningTitle.isDisplayed() } + } + step("Assert warning dialog message is displayed") { + ScanWarningDialogPageObject { warningMessage.isDisplayed() } + } + step("Assert 'Cancel' button is displayed") { + ScanWarningDialogPageObject { cancelButton.isDisplayed() } + } + step("Assert 'How to scan' button is displayed") { + ScanWarningDialogPageObject { howToScanButton.isDisplayed() } + } + step("Assert 'Request support' button is displayed") { + ScanWarningDialogPageObject { requestSupportButton.isDisplayed() } + } +} + +fun checkAlreadyUsedWalletDialog() { + step("Assert 'Already used Wallet' dialog title is displayed") { + AlreadyUsedWalletDialogPageObject { title.isDisplayed() } + } + step("Assert dialog message is displayed") { + AlreadyUsedWalletDialogPageObject { message.isDisplayed() } + } + step("Assert 'This is my wallet' button is displayed") { + AlreadyUsedWalletDialogPageObject { message.isDisplayed() } + } + step("Assert 'Cancel' button is displayed") { + AlreadyUsedWalletDialogPageObject { cancelButton.isDisplayed() } + } + step("Assert 'Request support' button is displayed") { + AlreadyUsedWalletDialogPageObject { requestSupportButton.isDisplayed() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt deleted file mode 100644 index dd160f1b25..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/OpenMainScreenScenario.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.scenarios - -import androidx.compose.ui.test.junit4.ComposeTestRule -import com.kaspersky.kaspresso.testcases.api.scenario.Scenario -import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext -import com.tangem.common.extensions.clickWithAssertion -import com.tangem.domain.models.scan.ProductType -import com.tangem.screens.* -import com.tangem.tap.domain.sdk.mocks.MockProvider -import io.github.kakaocup.compose.node.element.ComposeScreen - -class OpenMainScreenScenario( - private val testRule: ComposeTestRule, - private val productType: ProductType? = null, -) : Scenario() { - override val steps: TestContext.() -> Unit = { - if (productType != null) { - MockProvider.setMocks(productType) - } - ComposeScreen.onComposeScreen(testRule) { - step("Click on \"Accept\" button") { - acceptButton.clickWithAssertion() - } - } - ComposeScreen.onComposeScreen(testRule) { - step("Click on \"Scan\" button") { - scanButton.clickWithAssertion() - } - } - ComposeScreen.onComposeScreen(testRule) { - step("Make sure wallet screen is visible") { - assertIsDisplayed() - } - } - ComposeScreen.onComposeScreen(testRule) { - step("Close Markets tooltip"){ - contentContainer.performClick() - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/steps/WalletConnectScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt similarity index 99% rename from app/src/androidTest/kotlin/com/tangem/steps/WalletConnectScenarios.kt rename to app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt index 77134c2ea3..107806004a 100644 --- a/app/src/androidTest/kotlin/com/tangem/steps/WalletConnectScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt @@ -1,4 +1,4 @@ -package com.tangem.steps +package com.tangem.scenarios import com.tangem.common.BaseTestCase import com.tangem.screens.onWalletConnectBottomSheet diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt new file mode 100644 index 0000000000..7f5e42cc48 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt @@ -0,0 +1,32 @@ +package com.tangem.screens + +import com.kaspersky.kaspresso.screens.KScreen +import com.tangem.wallet.R +import io.github.kakaocup.kakao.text.KTextView +import io.github.kakaocup.kakao.text.KButton + +object AlreadyUsedWalletDialogPageObject : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + val title = KTextView { + withText(R.string.security_alert_title) + } + + val message = KTextView { + withText(R.string.wallet_been_activated_message) + } + + val cancelButton = KButton { + withText(R.string.common_cancel) + } + + val requestSupportButton = KButton { + withText(R.string.alert_button_request_support) + } + + val thisIsMyWalletButton = KButton { + withText(R.string.this_is_my_wallet_title) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index 5bac035afc..e60f497bc3 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -4,7 +4,7 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.R import com.tangem.core.ui.test.BaseButtonTestTags -import com.tangem.core.ui.test.DialogTestTags +import com.tangem.core.ui.test.BaseDialogTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode @@ -14,7 +14,7 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { val dialogContainer: KNode = child { - hasTestTag(DialogTestTags.DIALOG_CONTAINER) + hasTestTag(BaseDialogTestTags.CONTAINER) } val cancelButton: KNode = child { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/FailedTransactionDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/FailedTransactionDialogPageObject.kt new file mode 100644 index 0000000000..4a00a7e1b8 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/FailedTransactionDialogPageObject.kt @@ -0,0 +1,44 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.BaseDialogTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.common.ui.R as CommonUIR + +class FailedTransactionDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val dialogContainer: KNode = child { + hasTestTag(BaseDialogTestTags.CONTAINER) + } + + val title: KNode = child { + hasTestTag(BaseDialogTestTags.TITLE) + hasText(getResourceString(CommonUIR.string.send_alert_transaction_failed_title)) + useUnmergedTree = true + } + + val text: KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + useUnmergedTree = true + } + + val cancelButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_cancel)) + } + + val supportButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_support)) + } +} + +internal fun BaseTestCase.onFailedTransactionDialog(function: FailedTransactionDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 56742890d9..59419f5f41 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -39,7 +39,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasTestTag(MainScreenTestTags.SCREEN_CONTAINER) } - val synchronizeAddressesButton: KNode = child { + val synchronizeAddressesButton: KNode = lazyList.child { hasText(getResourceString(R.string.common_generate_addresses)) } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ScanWarningDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ScanWarningDialogPageObject.kt new file mode 100644 index 0000000000..245f08e61d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ScanWarningDialogPageObject.kt @@ -0,0 +1,36 @@ +package com.tangem.screens + +import com.kaspersky.kaspresso.screens.KScreen +import com.tangem.wallet.R +import io.github.kakaocup.kakao.text.KTextView +import io.github.kakaocup.kakao.text.KButton + +object ScanWarningDialogPageObject : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + val warningTitle = KTextView { + withText(R.string.common_warning) + } + + val warningMessage = KTextView { + withText(R.string.alert_troubleshooting_scan_card_title) + } + + val tryAgainButton = KButton { + withId(R.id.try_again_button) + } + + val howToScanButton = KButton { + withId(R.id.how_to_scan_button) + } + + val requestSupportButton = KButton { + withId(R.id.request_support_button) + } + + val cancelButton = KButton { + withId(R.id.cancel_button) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt new file mode 100644 index 0000000000..8186ff1683 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -0,0 +1,30 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.SendAddressScreenTestTags +import com.tangem.features.send.v2.impl.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val addressTextField: KNode = child { + hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD) + useUnmergedTree = true + } + + val nextButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_next)) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onSendAddressScreen(function: SendAddressPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt new file mode 100644 index 0000000000..900ead781c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -0,0 +1,30 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + useUnmergedTree = true + } + + val sendButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_send)) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onSendConfirmScreen(function: SendConfirmPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt similarity index 61% rename from app/src/androidTest/kotlin/com/tangem/screens/StakingSendPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt index f71c9ad210..f988ad6de5 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -3,7 +3,7 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.BaseButtonTestTags -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.core.ui.test.TopAppBarTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -12,11 +12,11 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString import com.tangem.features.send.v2.impl.R as SendR import androidx.compose.ui.test.hasTestTag as withTestTag -class StakingSendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { val screenContainer: KNode = child { - hasTestTag(StakingSendScreenTestTags.SCREEN_CONTAINER) + hasTestTag(SendScreenTestTags.SCREEN_CONTAINER) } val title: KNode = child { @@ -25,44 +25,44 @@ class StakingSendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } val amountContainerTitle: KNode = child { - hasTestTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TITLE) + hasTestTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE) useUnmergedTree = true } val amountContainerText: KNode = child { - hasTestTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TEXT) + hasTestTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT) useUnmergedTree = true } val amountInputTextField: KNode = child { - hasTestTag(StakingSendScreenTestTags.INPUT_TEXT_FIELD) + hasTestTag(SendScreenTestTags.INPUT_TEXT_FIELD) useUnmergedTree = true } val secondaryAmount: KNode = child { - hasTestTag(StakingSendScreenTestTags.SECONDARY_AMOUNT) + hasTestTag(SendScreenTestTags.SECONDARY_AMOUNT) useUnmergedTree = true } val currencyButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(StakingSendScreenTestTags.CURRENCY_ICON)) + hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) + hasAnyChild(withTestTag(SendScreenTestTags.CURRENCY_ICON)) useUnmergedTree = true } val fiatButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(StakingSendScreenTestTags.FIAT_ICON)) + hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) + hasAnyChild(withTestTag(SendScreenTestTags.FIAT_ICON)) useUnmergedTree = true } val maxButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.MAX_BUTTON) + hasTestTag(SendScreenTestTags.MAX_BUTTON) useUnmergedTree = true } val previousButton: KNode = child { - hasTestTag(StakingSendScreenTestTags.PREVIOUS_BUTTON) + hasTestTag(SendScreenTestTags.PREVIOUS_BUTTON) useUnmergedTree = true } @@ -74,5 +74,5 @@ class StakingSendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } -internal fun BaseTestCase.onStakingSendScreen(function: StakingSendPageObject.() -> Unit) = +internal fun BaseTestCase.onSendScreen(function: SendPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/StakingConfirmPageObject.kt similarity index 75% rename from app/src/androidTest/kotlin/com/tangem/screens/StakingSendDetailsPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/StakingConfirmPageObject.kt index 79b38d7c9c..e9a7eb2983 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/StakingSendDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/StakingConfirmPageObject.kt @@ -3,6 +3,7 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseAmountBlockTestTags import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.StakingSendDetailsScreenTestTags import com.tangem.core.ui.test.TopAppBarTestTags @@ -11,8 +12,8 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString -class StakingSendDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class StakingConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { val title: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) @@ -20,12 +21,12 @@ class StakingSendDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsP } val primaryAmount: KNode = child { - hasTestTag(StakingSendDetailsScreenTestTags.PRIMARY_AMOUNT) + hasTestTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT) useUnmergedTree = true } val secondaryAmount: KNode = child { - hasTestTag(StakingSendDetailsScreenTestTags.SECONDARY_AMOUNT) + hasTestTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT) useUnmergedTree = true } @@ -47,5 +48,5 @@ class StakingSendDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsP } -internal fun BaseTestCase.onStakingSendDetailsScreen(function: StakingSendDetailsPageObject.() -> Unit) = +internal fun BaseTestCase.onStakingConfirmScreen(function: StakingConfirmPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 128a24ad03..d17b8a09c2 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -111,6 +111,12 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasText(getResourceString(R.string.common_buy)) } + @OptIn(ExperimentalTestApi::class) + val sendButton: LazyListItemNode = horizontalActionChips.childWith { + hasTestTag(TokenDetailsScreenTestTags.ACTION_BUTTON) + hasText(getResourceString(R.string.common_send)) + } + } internal fun BaseTestCase.onTokenDetailsScreen(function: TokenDetailsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt b/app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt deleted file mode 100644 index 832a1b9fe8..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/steps/DeepLinksScenarios.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.steps - -import android.content.Intent -import android.net.Uri -import androidx.test.core.app.ApplicationProvider - -fun openAppByDeepLink(deepLinkUri: String?) { - val deeplinkScheme = "tangem://wc?uri=" - val context = ApplicationProvider.getApplicationContext() - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplinkScheme + deepLinkUri)).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - context.startActivity(intent) -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt index f553c1cbe0..abea9913ef 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt @@ -6,8 +6,9 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -36,13 +37,10 @@ class BuyTokenTest : BaseTestCase() { setWireMockScenarioState(scenarioName, "Error") } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -84,13 +82,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -175,13 +170,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -250,13 +242,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -339,13 +328,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -437,13 +423,10 @@ class BuyTokenTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt index df318a9456..b60639cf4a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt @@ -3,7 +3,7 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType -import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.scenarios.openMainScreen import com.tangem.screens.onDetailsScreen import com.tangem.screens.onReferralProgramScreen import com.tangem.screens.onTopBar @@ -19,7 +19,9 @@ class DetailsTest : BaseTestCase() { @Test fun walletWithoutBackupDetailsTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule)) + step("Open 'Main Screen'") { + openMainScreen() + } onTopBar { step("Open wallet details") { moreButton.clickWithAssertion() @@ -67,7 +69,9 @@ class DetailsTest : BaseTestCase() { // @Test fun wallet2DetailsTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule, ProductType.Wallet2)) + step("Open 'Main Screen'") { + openMainScreen(productType = ProductType.Wallet2, alreadyActivatedDialogIsShown = true) + } onTopBar { step("Open wallet details") { moreButton.clickWithAssertion() @@ -115,7 +119,9 @@ class DetailsTest : BaseTestCase() { @Test fun noteDetailsTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule, ProductType.Note)) + step("Open 'Main Screen'") { + openMainScreen(ProductType.Note) + } onTopBar { step("Open wallet details") { moreButton.clickWithAssertion() @@ -162,7 +168,9 @@ class DetailsTest : BaseTestCase() { @Test fun validateReferralProgramScreenTest() = setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule)) + step("Open 'Main Screen'") { + openMainScreen() + } step("Open wallet details") { onTopBar { moreButton.clickWithAssertion() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt new file mode 100644 index 0000000000..f2426d8093 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -0,0 +1,176 @@ +package com.tangem.tests + +import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.redux.StateDialog +import com.tangem.scenarios.* +import com.tangem.screens.* +import com.tangem.tap.common.redux.global.GlobalAction +import com.tangem.tap.domain.sdk.mocks.MockProvider +import com.tangem.tap.store +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.Allure +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore +import org.junit.Test + +@HiltAndroidTest +class FeedbackTest : BaseTestCase() { + + @AllureId("894") + @DisplayName("Send feedback: from details") + @Test + fun sendFeedbackFromDetailsTest() { + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click 'Contact support' button") { + onDetailsScreen { contactSupportButton.clickWithAssertion() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } + + @AllureId("893") + @DisplayName("Send feedback: failed transaction") + @Test + fun sendFeedbackFromFailedTransactionTest() { + val balance = TOTAL_BALANCE + val tokenName = "Polygon" + val recipientAddress = RECIPIENT_ADDRESS + val sendAmount = "1" + + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click 'Send' button") { + onTokenDetailsScreen { sendButton.performClick() } + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Assert input text field has value: '$sendAmount'") { + onSendScreen { amountInputTextField.assertTextContains(value = sendAmount, substring = true) } + } + step("Click 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Enter address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Click 'Send' button") { + onSendConfirmScreen { sendButton.clickWithAssertion() } + } + step("Check 'Failed transaction' dialog") { + checkFailedTransactionDialog() + } + step("Click on 'Support' button") { + onFailedTransactionDialog { supportButton.performClick() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } + + @AllureId("3985") + @DisplayName("Send feedback: from 'Warning' dialog after card scan") + @Test + fun sendFeedbackFromScanScreenTest() { + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + MockProvider.resetEmulateError() + } + ).run { + Allure.step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + step("Set scanning error") { + MockProvider.setEmulateError(TangemSdkError.TagLost()) + } + step("Click on 'Scan' button") { + onStoriesScreen { scanButton.performClick() } + } + step("Force show 'Scan warning' dialog"){ + runOnUiThread { + val scanFailsState = StateDialog.ScanFailsDialog(source = StateDialog.ScanFailsSource.MAIN) + store.dispatch(GlobalAction.ShowDialog(scanFailsState)) + } + } + step("Check 'Scan warning' dialog") { + checkScanWarningDialog() + } + step("Click on 'Request support' button") { + ScanWarningDialogPageObject { requestSupportButton.click() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } + + @AllureId("3986") + @DisplayName("Send feedback: from scan already used wallet alert dialog") + @Ignore("TODO On CI Already used wallet doesn't displayed") + @Test + fun sendFeedbackAfterScanAlreadyUsedWalletTest() { + setupHooks( + additionalAfterSection = { + device.uiDevice.pressBack() + } + ).run { + step("Set mocks for Wallet2") { + MockProvider.setMocks(ProductType.Wallet2) + } + step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + step("Click on 'Scan' button") { + onStoriesScreen { scanButton.clickWithAssertion() } + } + step("Check 'Already used Wallet' dialog") { + checkAlreadyUsedWalletDialog() + } + step("Click on 'Request support' button") { + AlreadyUsedWalletDialogPageObject { requestSupportButton.click() } + } + step("Check 'Contact support' intent is called") { + checkSendEMailIntentCalled() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt index 706bd497d5..9e1efb911a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt @@ -3,8 +3,9 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -21,13 +22,10 @@ class HideTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE setupHooks().run { step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt index c708b29087..cf7f5747e6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/MainScreenTest.kt @@ -1,7 +1,7 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.scenarios.OpenMainScreenScenario +import com.tangem.scenarios.openMainScreen import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @@ -11,7 +11,9 @@ class MainScreenTest : BaseTestCase() { @Test fun goToMain() { setupHooks().run { - scenario(OpenMainScreenScenario(composeTestRule)) + step("Open 'Main Screen'") { + openMainScreen() + } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index 76d8e34a7a..e0f1d97826 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -5,9 +5,10 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.swipeUp -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.onMainScreen import com.tangem.screens.onOrganizeTokensScreen +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -23,8 +24,9 @@ class OrganizeTokensTest : BaseTestCase() { setupHooks().run { val tokenTitle = "Ethereum" val tokenNetwork = "Ethereum network" + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } step("Click on 'Synchronize addresses' button" ) { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } @@ -90,14 +92,12 @@ class OrganizeTokensTest : BaseTestCase() { val ethereumTitle = "Ethereum" val bitcoinTitle = "Bitcoin" val balance = TOTAL_BALANCE + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Check positions of tokens on 'Main Screen'") { onMainScreen { @@ -129,8 +129,9 @@ class OrganizeTokensTest : BaseTestCase() { setupHooks().run { val ethereumTitle = "Ethereum" val bitcoinTitle = "Bitcoin" + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } step("Click on 'Synchronize addresses' button" ) { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } @@ -178,14 +179,12 @@ class OrganizeTokensTest : BaseTestCase() { val polygonTitle = "Polygon" val polExMaticTitle = "POL (ex-MATIC)" val balance = TOTAL_BALANCE + step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Check positions of tokens on 'Main Screen'") { onMainScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index 083478f475..5eb2c98591 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -5,8 +5,9 @@ import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -20,7 +21,7 @@ class StakingTest : BaseTestCase() { @Test fun validateStakingBlockTest() { val tokenTitle = "POL (ex-MATIC)" - val balance = TOTAL_BALANCE + val balance = "$3,299.37" val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Staked" @@ -35,13 +36,13 @@ class StakingTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + step("Synchronize addresses") { + synchronizeAddresses(balance) } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Assert 'Organize tokens' button is displayed") { + onMainScreen { organizeTokensButton().assertIsDisplayed() } } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -75,7 +76,7 @@ class StakingTest : BaseTestCase() { @Test fun validateStakingMoreScreensTest() { val tokenTitle = "POL (ex-MATIC)" - val balance = TOTAL_BALANCE + val balance = "$3,299.37" val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Staked" val stakingAmount = "1" @@ -91,13 +92,13 @@ class StakingTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + step("Synchronize addresses") { + synchronizeAddresses(balance) } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Assert 'Organize tokens' button is displayed") { + onMainScreen { organizeTokensButton().assertIsDisplayed() } } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -151,73 +152,73 @@ class StakingTest : BaseTestCase() { onStakingDetailsScreen { stakeMoreButton.performClick() } } step("Assert 'Send' screen is displayed") { - onStakingSendScreen { screenContainer.assertIsDisplayed() } + onSendScreen { screenContainer.assertIsDisplayed() } } step("Assert 'Send' screen title is displayed") { - onStakingSendScreen { title.assertIsDisplayed() } + onSendScreen { title.assertIsDisplayed() } } step("Assert amount container title is displayed") { - onStakingSendScreen { amountContainerTitle.assertIsDisplayed() } + onSendScreen { amountContainerTitle.assertIsDisplayed() } } step("Assert amount container text is displayed") { - onStakingSendScreen { amountContainerText.assertIsDisplayed() } + onSendScreen { amountContainerText.assertIsDisplayed() } } step("Assert input text field is displayed") { - onStakingSendScreen { amountInputTextField.assertIsDisplayed() } + onSendScreen { amountInputTextField.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendScreen { secondaryAmount.assertIsDisplayed() } + onSendScreen { secondaryAmount.assertIsDisplayed() } } step("Type '$stakingAmount' in input text field") { - onStakingSendScreen { + onSendScreen { amountInputTextField.performClick() amountInputTextField.performTextReplacement(stakingAmount) } } step("Assert input text field has value: '$stakingAmount'") { - onStakingSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } + onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert 'Max' button is displayed") { - onStakingSendScreen { maxButton.assertIsDisplayed() } + onSendScreen { maxButton.assertIsDisplayed() } } step("Assert previous button is displayed") { - onStakingSendScreen { previousButton.assertIsDisplayed() } + onSendScreen { previousButton.assertIsDisplayed() } } step("Assert 'Next' button is displayed") { - onStakingSendScreen { nextButton.assertIsDisplayed() } + onSendScreen { nextButton.assertIsDisplayed() } } step("Click on 'Next' button") { - onStakingSendScreen { nextButton.performClick() } + onSendScreen { nextButton.performClick() } } step("Assert 'Send details' screen title is displayed") { - onStakingSendDetailsScreen { title.assertIsDisplayed() } + onStakingConfirmScreen { title.assertIsDisplayed() } } step("Assert primary amount is displayed") { - onStakingSendDetailsScreen { primaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendDetailsScreen { secondaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } } step("Assert 'Validator' block is displayed") { - onStakingSendDetailsScreen { validatorBlock.assertIsDisplayed() } + onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } } step("Assert 'Network Fee' block is displayed") { - onStakingSendDetailsScreen { networkFeeBlock.assertIsDisplayed() } + onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } } step("Assert 'Stake' button is displayed") { - onStakingSendDetailsScreen { stakeButton.assertIsDisplayed() } + onStakingConfirmScreen { stakeButton.assertIsDisplayed() } } } } @@ -243,13 +244,13 @@ class StakingTest : BaseTestCase() { } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + step("Synchronize addresses") { + synchronizeAddresses(balance) } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Assert 'Organize tokens' button is displayed") { + onMainScreen { organizeTokensButton().assertIsDisplayed() } } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -306,73 +307,73 @@ class StakingTest : BaseTestCase() { onStakingDetailsScreen { stakeButton.performClick() } } step("Assert 'Send' screen is displayed") { - onStakingSendScreen { screenContainer.assertIsDisplayed() } + onSendScreen { screenContainer.assertIsDisplayed() } } step("Assert 'Send' screen title is displayed") { - onStakingSendScreen { title.assertIsDisplayed() } + onSendScreen { title.assertIsDisplayed() } } step("Assert amount container title is displayed") { - onStakingSendScreen { amountContainerTitle.assertIsDisplayed() } + onSendScreen { amountContainerTitle.assertIsDisplayed() } } step("Assert amount container text is displayed") { - onStakingSendScreen { amountContainerText.assertIsDisplayed() } + onSendScreen { amountContainerText.assertIsDisplayed() } } step("Assert input text field is displayed") { - onStakingSendScreen { amountInputTextField.assertIsDisplayed() } + onSendScreen { amountInputTextField.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendScreen { secondaryAmount.assertIsDisplayed() } + onSendScreen { secondaryAmount.assertIsDisplayed() } } step("Type '$stakingAmount' in input text field") { - onStakingSendScreen { + onSendScreen { amountInputTextField.performClick() amountInputTextField.performTextReplacement(stakingAmount) } } step("Assert input text field has value: '$stakingAmount'") { - onStakingSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } + onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert currency button is displayed") { - onStakingSendScreen { currencyButton.assertIsDisplayed() } + onSendScreen { currencyButton.assertIsDisplayed() } } step("Assert fiat button is displayed") { - onStakingSendScreen { fiatButton.assertIsDisplayed() } + onSendScreen { fiatButton.assertIsDisplayed() } } step("Assert 'Max' button is displayed") { - onStakingSendScreen { maxButton.assertIsDisplayed() } + onSendScreen { maxButton.assertIsDisplayed() } } step("Assert previous button is displayed") { - onStakingSendScreen { previousButton.assertIsDisplayed() } + onSendScreen { previousButton.assertIsDisplayed() } } step("Assert 'Next' button is displayed") { - onStakingSendScreen { nextButton.assertIsDisplayed() } + onSendScreen { nextButton.assertIsDisplayed() } } step("Click on 'Next' button") { - onStakingSendScreen { nextButton.performClick() } + onSendScreen { nextButton.performClick() } } step("Assert 'Send details' screen title is displayed") { - onStakingSendDetailsScreen { title.assertIsDisplayed() } + onStakingConfirmScreen { title.assertIsDisplayed() } } step("Assert primary amount is displayed") { - onStakingSendDetailsScreen { primaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } } step("Assert secondary amount is displayed") { - onStakingSendDetailsScreen { secondaryAmount.assertIsDisplayed() } + onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } } step("Assert 'Validator' block is displayed") { - onStakingSendDetailsScreen { validatorBlock.assertIsDisplayed() } + onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } } step("Assert 'Network Fee' block is displayed") { - onStakingSendDetailsScreen { networkFeeBlock.assertIsDisplayed() } + onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } } step("Assert 'Stake' button is displayed") { - onStakingSendDetailsScreen { stakeButton.assertIsDisplayed() } + onStakingConfirmScreen { stakeButton.assertIsDisplayed() } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt index 638218b875..887bffc37a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt @@ -9,8 +9,9 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -32,13 +33,10 @@ class SwapTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -111,13 +109,10 @@ class SwapTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -149,7 +144,7 @@ class SwapTokenTest : BaseTestCase() { @ApiEnv( ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) ) - @AllureId("3546") + @AllureId("3547") @DisplayName("Swap: change network fee") @Test fun changeNetworkFeeTest() { @@ -159,13 +154,10 @@ class SwapTokenTest : BaseTestCase() { val balance = TOTAL_BALANCE step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } - step("Click on 'Synchronize addresses' button") { - onMainScreen { synchronizeAddressesButton.clickWithAssertion() } - } - step("Assert wallet balance = $balance") { - onMainScreen { walletBalance().assertTextContains(balance) } + step("Synchronize addresses") { + synchronizeAddresses(balance) } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt index ff9f1239bd..670444ff59 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -5,9 +5,8 @@ import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.swipeUp import com.tangem.common.utils.getWcUri -import com.tangem.scenarios.OpenMainScreenScenario import com.tangem.screens.* -import com.tangem.steps.* +import com.tangem.scenarios.* import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -147,7 +146,7 @@ class WalletConnectTest : BaseTestCase() { openAppByDeepLink(deepLinkUri) } step("Open 'Main Screen'") { - scenario(OpenMainScreenScenario(composeTestRule)) + openMainScreen() } step("Check 'Wallet Connect' bottom sheet") { checkWalletConnectBottomSheet() diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt index 47418a9338..8e21c12dd9 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt @@ -26,7 +26,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.StakingSendDetailsScreenTestTags +import com.tangem.core.ui.test.BaseAmountBlockTestTags import java.math.BigDecimal @Composable @@ -66,7 +66,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens.spacing24) - .testTag(StakingSendDetailsScreenTestTags.PRIMARY_AMOUNT), + .testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), ) Text( text = secondAmount, @@ -76,7 +76,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens.spacing8) - .testTag(StakingSendDetailsScreenTestTags.SECONDARY_AMOUNT), + .testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt index 753a0fc99e..6263c12b93 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt @@ -23,7 +23,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import kotlinx.collections.immutable.PersistentList private const val AMOUNT_BUTTONS_KEY = "amountButtonsKey" @@ -80,7 +80,7 @@ internal fun LazyListScope.buttons( vertical = TangemTheme.dimens.spacing10, horizontal = TangemTheme.dimens.spacing34, ) - .testTag(StakingSendScreenTestTags.MAX_BUTTON), + .testTag(SendScreenTestTags.MAX_BUTTON), ) } } @@ -94,7 +94,7 @@ private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegment .padding( horizontal = TangemTheme.dimens.spacing10, ) - .testTag(StakingSendScreenTestTags.CURRENCY_BUTTON), + .testTag(SendScreenTestTags.CURRENCY_BUTTON), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { @@ -106,13 +106,13 @@ private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegment url = button.iconUrl, size = TangemTheme.dimens.size18, isGrayscale = !isSegmentedButtonsEnabled, - modifier = iconModifier.testTag(StakingSendScreenTestTags.FIAT_ICON), + modifier = iconModifier.testTag(SendScreenTestTags.FIAT_ICON), ) } else if (button.iconState != null) { CurrencyIcon( state = button.iconState, shouldDisplayNetwork = false, - modifier = iconModifier.testTag(StakingSendScreenTestTags.CURRENCY_ICON), + modifier = iconModifier.testTag(SendScreenTestTags.CURRENCY_ICON), ) } Text( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt index f491629420..848bd0ab36 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt @@ -29,7 +29,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.core.ui.utils.rememberDecimalFormat import kotlinx.coroutines.delay @@ -119,7 +119,7 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri modifier = Modifier .align(TopCenter) .padding(bottom = TangemTheme.dimens.spacing32) - .testTag(StakingSendScreenTestTags.SECONDARY_AMOUNT), + .testTag(SendScreenTestTags.SECONDARY_AMOUNT), ) AmountFieldError( isError = amountField.isError, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt index d831aad665..6fd563f173 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt @@ -29,7 +29,7 @@ import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags private const val AMOUNT_FIELD_KEY = "amountFieldKey" @@ -54,7 +54,7 @@ internal fun LazyListScope.amountField( color = TangemTheme.colors.text.tertiary, modifier = Modifier .padding(top = TangemTheme.dimens.spacing14) - .testTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TITLE), + .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), ) val balance = amountState.availableBalance.orMaskWithStars(isBalanceHidden).resolveReference() @@ -69,7 +69,7 @@ internal fun LazyListScope.amountField( textAlign = TextAlign.Center, modifier = Modifier .padding(top = TangemTheme.dimens.spacing2) - .testTag(StakingSendScreenTestTags.AMOUNT_CONTAINER_TEXT), + .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT), ) } CurrencyIcon( diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt index b22f5341e4..496559367f 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt @@ -36,7 +36,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.singleEvent -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags @Composable fun NavigationButtonsBlock( @@ -149,7 +149,7 @@ private fun PreviousButton(prevButton: NavigationButton?) { .background(TangemTheme.colors.button.secondary) .clickable(onClick = button.onClick) .padding(TangemTheme.dimens.spacing12) - .testTag(StakingSendScreenTestTags.PREVIOUS_BUTTON), + .testTag(SendScreenTestTags.PREVIOUS_BUTTON), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt index bf1fc3a926..b8955c8b80 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt @@ -30,7 +30,7 @@ import com.tangem.core.ui.components.fields.SimpleDialogTextField import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.DialogTestTags +import com.tangem.core.ui.test.BaseDialogTestTags import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -219,13 +219,14 @@ private fun TangemDialog( color = TangemTheme.colors.background.primary, ) .padding(vertical = TangemTheme.dimens.spacing24) - .testTag(DialogTestTags.DIALOG_CONTAINER), + .testTag(BaseDialogTestTags.CONTAINER), ) { if (title != null) { Text( modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing24) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(BaseDialogTestTags.TITLE), text = title, style = when (type) { is DialogType.Message -> TangemTheme.typography.h2 @@ -262,7 +263,8 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) { Text( modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing24) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(BaseDialogTestTags.TEXT), text = type.message, style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt index 2219e2642d..3ec5ffc54c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.core.ui.utils.* import java.math.BigDecimal import java.text.DecimalFormat @@ -106,7 +106,7 @@ fun AmountTextField( visualTransformation = visualTransformation, modifier = Modifier .background(backgroundColor) - .testTag(StakingSendScreenTestTags.INPUT_TEXT_FIELD), + .testTag(SendScreenTestTags.INPUT_TEXT_FIELD), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt index 259553da0b..759d32590b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment.Companion.CenterEnd import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -32,6 +33,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.core.ui.utils.DEFAULT_ANIMATION_DURATION import kotlinx.coroutines.delay @@ -92,6 +94,7 @@ fun InputRowRecipient( text = it.resolveReference(), style = TangemTheme.typography.subtitle2, color = color, + modifier = Modifier.testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD_TITLE), ) } Box( @@ -115,7 +118,8 @@ fun InputRowRecipient( modifier = Modifier .padding(start = TangemTheme.dimens.spacing12) .weight(1f) - .align(CenterVertically), + .align(CenterVertically) + .testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD), ) CrossIcon( onClick = onPasteClick, diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt new file mode 100644 index 0000000000..f2f436d707 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object BaseAmountBlockTestTags { + const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT" + const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseDialogTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseDialogTestTags.kt new file mode 100644 index 0000000000..9ed0a4fbd6 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseDialogTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object BaseDialogTestTags { + const val CONTAINER = "BASE_DIALOG_CONTAINER" + const val TITLE = "BASE_DIALOG_TITLE" + const val TEXT = "BASE_DIALOG_TEXT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/DialogTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/DialogTestTags.kt deleted file mode 100644 index 0c5eae6e75..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/test/DialogTestTags.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.core.ui.test - -object DialogTestTags { - const val DIALOG_CONTAINER = "DIALOG_CONTAINER" -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt new file mode 100644 index 0000000000..ebe05bc895 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object SendAddressScreenTestTags { + const val ADDRESS_TEXT_FIELD_TITLE = "SEND_ADDRESS_TEXT_FIELD_TITLE" + const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_TEXT_FIELD" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt new file mode 100644 index 0000000000..bc025954ac --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt @@ -0,0 +1,16 @@ +package com.tangem.core.ui.test + +object SendScreenTestTags { + const val SCREEN_CONTAINER = "SEND_SCREEN_CONTAINER" + + const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE" + const val AMOUNT_CONTAINER_TEXT = "SEND_SCREEN_AMOUNT_CONTAINER_TEXT" + const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD" + const val SECONDARY_AMOUNT = "SEND_SCREEN_SECONDARY_AMOUNT" + + const val CURRENCY_BUTTON = "SEND_SCREEN_CURRENCY_BUTTON" + const val FIAT_ICON = "SEND_SCREEN_FIAT_ICON" + const val CURRENCY_ICON = "SEND_SCREEN_CURRENCY_ICON" + const val MAX_BUTTON = "END_SCREEN_MAX_BUTTON" + const val PREVIOUS_BUTTON = "SEND_SCREEN_PREVIOUS_BUTTON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt index 138b27e35b..22cbf9c8a5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendDetailsScreenTestTags.kt @@ -1,10 +1,6 @@ package com.tangem.core.ui.test object StakingSendDetailsScreenTestTags { - - const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT" - const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT" - - const val VALIDATOR_BLOCK = "TAKING_SEND_DETAILS_SCREEN_VALIDATOR_BLOCK" - const val NETWORK_FEE_BLOCK = "TAKING_SEND_DETAILS_SCREEN_NETWORK_FEE_BLOCK" + const val VALIDATOR_BLOCK = "STAKING_SEND_DETAILS_SCREEN_VALIDATOR_BLOCK" + const val NETWORK_FEE_BLOCK = "STAKING_SEND_DETAILS_SCREEN_NETWORK_FEE_BLOCK" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendScreenTestTags.kt deleted file mode 100644 index 1dc36d3f79..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/test/StakingSendScreenTestTags.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.core.ui.test - -object StakingSendScreenTestTags { - const val SCREEN_CONTAINER = "STAKING_SEND_SCREEN_CONTAINER" - - const val AMOUNT_CONTAINER_TITLE = "STAKING_SEND_SCREEN_AMOUNT_CONTAINER_TITLE" - const val AMOUNT_CONTAINER_TEXT = "STAKING_SEND_SCREEN_AMOUNT_CONTAINER_TEXT" - const val INPUT_TEXT_FIELD = "STAKING_SEND_SCREEN_INPUT_TEXT_FIELD" - const val SECONDARY_AMOUNT = "STAKING_SEND_SCREEN_SECONDARY_AMOUNT" - - const val CURRENCY_BUTTON = "STAKING_SEND_SCREEN_CURRENCY_BUTTON" - const val FIAT_ICON = "STAKING_SEND_SCREEN_FIAT_ICON" - const val CURRENCY_ICON = "STAKING_SEND_SCREEN_CURRENCY_ICON" - const val MAX_BUTTON = "STAKING_SEND_SCREEN_MAX_BUTTON" - const val PREVIOUS_BUTTON = "STAKING_SEND_SCREEN_PREVIOUS_BUTTON" -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt index e1a7c81bd2..5170756b32 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -21,7 +21,7 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.StakingSendScreenTestTags +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.StakingStep @@ -47,7 +47,7 @@ internal fun StakingScreen(uiState: StakingUiState) { .fillMaxSize() .imePadding() .systemBarsPadding() - .testTag(StakingSendScreenTestTags.SCREEN_CONTAINER), + .testTag(SendScreenTestTags.SCREEN_CONTAINER), horizontalAlignment = Alignment.CenterHorizontally, ) { StakingAppBar( diff --git a/tangem-android-tools b/tangem-android-tools index 794a8187e6..4c0c522992 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit 794a8187e6d248ca3c21661df199a34ffeb0037a +Subproject commit 4c0c522992e9ae84c42eeb45564a56b84fe337ef From 155e7e3d2c3f25d2844e4ad14e8ea09eb9a490e3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Sep 2025 16:22:54 +0300 Subject: [PATCH 063/132] Updated on 2026-08-14 --- .../di/UserWalletsListManagerModule.kt | 3 --- .../DefaultUserWalletsListRepository.kt | 18 +++++------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt index f582dea772..c521709443 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt @@ -8,7 +8,6 @@ import com.tangem.common.json.TangemSdkAdapter import com.tangem.common.services.secure.SecureStorage import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.models.scan.serialization.* import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.domain.visa.model.VisaCardActivationStatus @@ -123,7 +122,6 @@ internal object UserWalletsListManagerModule { passwordRequester: HotWalletPasswordRequester, appPreferencesStore: AppPreferencesStore, hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, - scanCardProcessor: ScanCardProcessor, ): UserWalletsListRepository { val moshi = buildMoshi() val secureStorage = buildSecureStorage(applicationContext = applicationContext) @@ -170,7 +168,6 @@ internal object UserWalletsListManagerModule { appPreferencesStore = appPreferencesStore, savePersistentInformation = ProviderSuspend { true }, // Always save persistent information for now hotWalletAccessCodeAttemptsRepository = hotWalletAccessCodeAttemptsRepository, - scanCardProcessor = scanCardProcessor, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index c6204135b7..fd30adec16 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -11,22 +11,16 @@ import com.tangem.common.map import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getSyncOrDefault -import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository.LockMethod +import com.tangem.domain.core.wallets.error.* import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.wallets.R import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.domain.wallets.hot.HotWalletPasswordRequester -import com.tangem.domain.core.wallets.error.DeleteWalletError -import com.tangem.domain.core.wallets.error.LockWalletsError -import com.tangem.domain.core.wallets.error.SaveWalletError -import com.tangem.domain.core.wallets.error.SelectWalletError -import com.tangem.domain.core.wallets.error.SetLockError -import com.tangem.domain.core.wallets.error.UnlockWalletError -import com.tangem.domain.core.wallets.UserWalletsListRepository -import com.tangem.domain.core.wallets.UserWalletsListRepository.LockMethod import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository +import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.hot.sdk.model.HotWalletId import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey @@ -39,7 +33,6 @@ import com.tangem.utils.ProviderSuspend import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import com.tangem.core.analytics.models.AnalyticsParam import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -54,7 +47,6 @@ internal class DefaultUserWalletsListRepository( private val savePersistentInformation: ProviderSuspend, private val appPreferencesStore: AppPreferencesStore, private val hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, - private val scanCardProcessor: ScanCardProcessor, ) : UserWalletsListRepository { override val userWallets = MutableStateFlow?>(null) @@ -270,7 +262,7 @@ internal class DefaultUserWalletsListRepository( raise(UnlockWalletError.UnableToUnlock) } - scanCardProcessor.scan(analyticsSource = AnalyticsParam.ScreensSources.SignIn) + tangemSdkManagerProvider().scanProduct() .doOnSuccess { scanResponse -> val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build() From e0a5bf6e9e79b80fdb37a8cb12d26d9c986852ff Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 11:17:04 +0400 Subject: [PATCH 064/132] Updated on 2026-08-14 --- data/visa/build.gradle.kts | 1 + .../tangem/data/pay/di/TangemPayDataModule.kt | 8 ++++- .../pay/repository/DefaultKycRepository.kt | 10 ++---- .../pay/usecase/DefaultKycStartInfoUseCase.kt | 36 +++++++++++++++++++ .../domain/pay/repository/KycRepository.kt | 4 --- .../domain/pay/usecase/KycStartInfoUseCase.kt | 10 ++++++ .../com/tangem/features/kyc/KycComponent.kt | 7 +--- .../features/kyc/DefaultKycComponent.kt | 4 +-- .../tangem/features/kyc/DefaultKycModel.kt | 11 +++--- 9 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 38d1d2957a..05adcdc660 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.appCurrency.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) /** Project - Utils */ implementation(projects.core.utils) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 727e31f796..aab7f3c69d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -1,7 +1,9 @@ package com.tangem.data.pay.di import com.tangem.data.pay.repository.DefaultKycRepository +import com.tangem.data.pay.usecase.DefaultKycStartInfoUseCase import com.tangem.domain.pay.repository.KycRepository +import com.tangem.domain.pay.usecase.KycStartInfoUseCase import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,5 +16,9 @@ internal interface TangemPayDataModule { @Binds @Singleton - fun bindKycRepositoryFactory(factory: DefaultKycRepository.Factory): KycRepository.Factory + fun bindKycRepository(repository: DefaultKycRepository): KycRepository + + @Binds + @Singleton + fun bindKycStartInfoUseCase(useCase: DefaultKycStartInfoUseCase): KycStartInfoUseCase } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt index 5ecfd916c5..20a9056985 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt @@ -13,10 +13,9 @@ import com.tangem.domain.pay.KycStartInfo import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.visa.error.VisaApiError -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import javax.inject.Inject -class DefaultKycRepository @AssistedInject constructor( +class DefaultKycRepository @Inject constructor( @NetworkMoshi moshi: Moshi, private val tangemPayApi: TangemPayApi, private val authDataSource: TangemPayAuthDataSource, @@ -65,9 +64,4 @@ class DefaultKycRepository @AssistedInject constructor( return Either.Left(VisaApiError.UnknownWithoutCode) } } - - @AssistedFactory - interface Factory : KycRepository.Factory { - override fun create(): DefaultKycRepository - } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt new file mode 100644 index 0000000000..1ee376e005 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt @@ -0,0 +1,36 @@ +package com.tangem.data.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.error.UniversalError +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.KycStartInfo +import com.tangem.domain.pay.repository.KycRepository +import com.tangem.domain.pay.usecase.KycStartInfoUseCase +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.visa.error.VisaApiError +import javax.inject.Inject + +/** + * For TangemPay Customer Wallet auth we are using polygon address + */ +private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token" + +internal class DefaultKycStartInfoUseCase @Inject constructor( + private val userWalletsRepository: UserWalletsListRepository, + private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val kycRepository: KycRepository, +) : KycStartInfoUseCase { + + override suspend fun invoke(): Either = either { + val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold + ?: raise(VisaApiError.UnknownWithoutCode) + + val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE)) + .getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode) + + kycRepository.getKycStartInfo(address, wallet.cardId).bind() + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt index 5d4ef99fd3..675923877b 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt @@ -15,8 +15,4 @@ interface KycRepository { * Returns KYC data to continue the survey. Used when KYC wasn't finished by the user */ suspend fun getKycStartInfo(authHeader: String): Either - - interface Factory { - fun create(): KycRepository - } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt new file mode 100644 index 0000000000..8736279c57 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.pay.KycStartInfo + +interface KycStartInfoUseCase { + + suspend operator fun invoke(): Either +} \ No newline at end of file diff --git a/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt b/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt index de05f5dc62..6a18888000 100644 --- a/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt +++ b/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt @@ -4,14 +4,9 @@ import com.tangem.core.decompose.context.AppComponentContext interface KycComponent { - fun launch(params: Params) + fun launch() interface Factory { fun create(appComponentContext: AppComponentContext): KycComponent } - - data class Params( - val targetAddress: String, - val cardId: String, - ) } \ No newline at end of file diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt index 92d39cee70..596b46c3ac 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt @@ -18,7 +18,7 @@ class DefaultKycComponent @AssistedInject constructor( private val model: DefaultKycModel = getOrCreateModel() - override fun launch(params: KycComponent.Params) { + override fun launch() { componentScope.launch { model.uiState.collect { it?.let { startInfo -> @@ -35,7 +35,7 @@ class DefaultKycComponent @AssistedInject constructor( } } } - model.getKycToken(params) + model.getKycToken() } @AssistedFactory diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt index aff81ad334..c46ad85577 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.pay.KycStartInfo -import com.tangem.domain.pay.repository.KycRepository +import com.tangem.domain.pay.usecase.KycStartInfoUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,18 +15,15 @@ import javax.inject.Inject @ModelScoped class DefaultKycModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - kycRepositoryFactory: KycRepository.Factory, + private val kycStartInfoUseCase: KycStartInfoUseCase, ) : Model() { - private val kycRepository = kycRepositoryFactory.create() - private val _uiState: MutableStateFlow = MutableStateFlow(null) val uiState = _uiState.asStateFlow() - fun getKycToken(params: KycComponent.Params) { + fun getKycToken() { modelScope.launch { - kycRepository.getKycStartInfo(address = params.targetAddress, cardId = params.cardId).getOrNull() - ?.let { _uiState.emit(it) } + kycStartInfoUseCase().getOrNull()?.let { _uiState.emit(it) } } } } \ No newline at end of file From 6e514da61077b1236a20156868baa71aef302b0a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Sep 2025 10:45:42 +0200 Subject: [PATCH 065/132] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 3 +- core/res/src/main/res/values-es/strings.xml | 40 ++++---- core/res/src/main/res/values-fr/strings.xml | 3 +- core/res/src/main/res/values-ja/strings.xml | 92 ++++++++++++++++++- core/res/src/main/res/values-ru/strings.xml | 35 ++++++- .../src/main/res/values-uk-rUA/strings.xml | 22 ++++- core/res/src/main/res/values/strings.xml | 25 +++-- .../onramp/analytics/OnrampAnalyticsEvent.kt | 31 +++++++ .../alloffers/entity/AllOffersIntents.kt | 5 +- .../alloffers/entity/AllOffersStateFactory.kt | 7 +- .../onramp/alloffers/model/AllOffersModel.kt | 14 ++- .../onramp/main/entity/OnrampIntents.kt | 1 - .../main/model/OnrampMainComponentModel.kt | 2 - .../mainv2/entity/OnrampOfferBlockUM.kt | 23 ++++- .../onramp/mainv2/entity/OnrampV2Intents.kt | 13 +++ .../OnrampV2AmountFieldChangeConverter.kt | 3 +- .../factory/OnrampOffersStateFactory.kt | 4 +- .../factory/OnrampV2AmountStateFactory.kt | 3 +- .../entity/factory/OnrampV2StateFactory.kt | 3 +- .../model/OnrampV2MainComponentModel.kt | 14 +-- 20 files changed, 274 insertions(+), 69 deletions(-) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index b7cc837f73..a9bc17a583 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -984,7 +984,6 @@ Überprüfe deine Netzwerkverbindung Informationen zur Netzwerkgebühr nicht erreichbar Sie senden - Von Von %s Grenzwert Gasgebühr Dies ist die maximale Gasgebühr, die für den Abschluss einer Transaktion oder eines Vertrags ausgegeben wird. Ein Gaslimit verhindert unerwartete oder unbegrenzte Gebühren bei der Ausführung einer Transaktion. @@ -1141,7 +1140,7 @@ Um mit dem Staking bei TON zu beginnen, führe zunächst eine ausgehende Transaktion in beliebiger Höhe durch – dadurch wird Deine Wallet aktiviert. Für den Abschluss der Transaktion können zusätzlich zur Netzwerkgebühr bis zu 0,2 TON erforderlich sein. Nicht genutzte Beträge werden zurückerstattet. Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 TON erforderlich. Bitte lade Dein Guthaben auf. - 0,2 Ton reserviert + TON-Reserve erforderlich Durch diese Aktion werden andere Positionen geschlossen oder gemäß den Netzwerkregeln in den Auszahlungsstatus versetzt. Positionsstatus Entsperre dein Geld, um es aus dem Staking-Prozess abzuheben. Das Freischalten nimmt %s. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 16cac3a4f8..31e91c7d59 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -196,7 +196,7 @@ Sincronizar direcciones Ir al proveedor Ir al token - Entiendo + Entendido Ocultar hora Importe @@ -880,8 +880,7 @@ Memo Compruebe su conexión de red Información de tarifa de red no accesible - Envía - Desde + Usted envía De %s Límite de gas Este es el máximo de gas que se gastará para completar una transacción o contrato. Un límite de gas evita cargos inesperados o ilimitados al ejecutar una transacción. @@ -895,7 +894,7 @@ Cobertura de tarifa de red Nonce Número único para cada transacción. Utilícelo para reenviar o cancelar una transacción pendiente. - Introduzca nonce.. + Introduzca nonce... Fondos insuficientes para la transferencia, ya que el total de la tarifa y el importe de la transferencia supera el saldo existente El total supera el saldo Se requiere un saldo de al menos %s para mantener su cuenta en blockchain para evitar riesgos de seguridad. Este monto permanecerá en su saldo y no podrá retirarse. @@ -950,6 +949,8 @@ Cantidad no válida La tarifa excede el saldo La cantidad total excede el saldo + ¿Está seguro que desea cambiar el token a recibir? Esto restablecerá los datos introducidos previamente. + Cambio de token Intercambiar y enviar ¿Continuar con la conversión? Esto borrará sus datos anteriores. Confirmar Conversión @@ -961,6 +962,7 @@ Cantidad a recibir El destinatario recibe %s ¿Seguro que desea cancelar la conversión? Se borrarán sus datos anteriores. + Eliminar conversión Enviar con swap Transacción enviada Escanee la tarjeta/anillo que quiere configurar @@ -968,10 +970,10 @@ Esto eliminará la wallet de la aplicación. La wallet en sí puede\nañadirse de nuevo. Nombre Haga que su token trabaje para Ud. - El monto del staking debe ser al menos %s - El monto del staking se redondeará a %1$s TRX debido a las reglas de la red. + La cantidad del staking debe ser al menos %s + La cantidad de staking se redondeará a %1$s TRX debido a las reglas de la red. El monto de cancelación del staking se redondeará a %1$s TRX debido a las reglas de la red. - Unstaking de la reclamación + Reclamar lo que no está en staking Tarifa de staking de la cuenta Una cuenta de staking es una cuenta especial donde se almacenan los tokens SOL de staking. Se crea cuando delegas sus tokens a un validador para participar en la validación de transacciones y ganar recompensas. Se cobra una pequeña tarifa por crear la cuenta de staking, que se devuelve una vez que se completa el staking. Porcentaje de rendimiento anual @@ -1007,7 +1009,7 @@ No hay validadores activos disponibles para hacer staking en este momento. Por favor, inténtalo de nuevo más tarde. Al hacer staking en la red Cardano, se utiliza todo su saldo. Se reservarán 2 ADA adicionales y se devolverán después del staking. Su ADA permanece desbloqueado mientras hace staking. Detalles del staking de ADA - Recomp. won se le enviará y estará disponible para su uso inmediato + Las recompensas obtenidas se enviarán a su billetera y estarán disponibles para su uso inmediato Haga staking de forma segura y comience a ganar recompensas Tangem permite a los usuarios hacer staking de sus criptomonedas Tangem permite a los usuarios hacer staking de sus criptomonedas @@ -1034,7 +1036,7 @@ Para empezar a hacer staking en TON, realice primero una transacción de salida de cualquier importe - esto activará su billetera. Es posible que se requieran hasta 0.2 TON además de la tarifa de red para completar la transacción. Cualquier cantidad no utilizada será reembolsada. Se requieren 0.2 TON para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo. - 0.2 Ton Reservado + Se requiere reserva de TON Esta acción cerrará otras posiciones o las cambiará al estado de retiro, de acuerdo con las reglas de la red. Estado de las posiciones Desbloquee su dinero para retirarlo del proceso de staking. El desbloqueo demora %s minuto. @@ -1044,9 +1046,9 @@ Hacer staking en la red Tron con un nuevo validador transferirá automáticamente todos los fondos de staking a este validador. Preparando Listo para retirar - Reunir - Haga el staking de nuevo - Restaking de las recompensas + Volver a unir + Hacer staking de nuevo + Hacer restaking de las recompensas Revocar Revotar Auto @@ -1080,7 +1082,7 @@ La cantidad para el unstaking debe ser al menos %s El monto excede el saldo apostado Sin staking - Unstaking + Deshacer el staking Detenando el staking de activos %s Validador Validadores @@ -1195,7 +1197,7 @@ Tangem Twin Esta acción es irreversible. No tendrá acceso la billetera antigua. Toque la tarjeta gemela con el número %s y no la retira hasta el final de la operación - Por favor, inténtelo de nuevo más tarde. Si el problema persiste, póngase en contacto con el servicio de asistencia. + Por favor, inténtelo de nuevo más tarde. Si el problema persiste, póngase en contacto con el servicio de soporte. ¡Algo salió mal! Hemos encontrado un error. Código de error: %s. Póngase en contacto con nuestro servicio de soporte. Use %s o escanee una tarjeta/anillo para tener acceso a su billetera @@ -1334,10 +1336,10 @@ Su opinión nos motiva a hacer Tangem Wallet aún mejor ¿Disfrutando de Tangem? Deba asociar su token antes de recibir tokens - Debe abrir una trustline para su token antes de recibirlo + Debe abrir una trustline (línea de confianza) para su token antes de recibirlo Se requiere tarifa de alquiler de red Acción requerida - ¿Se puso en contacto con el servicio de soporte a través de la app en los 7 días posteriores a la creación de la billetera? Si lo hiciste o no estás seguro, pulse en \"Sí\" y complete las instrucciones. + ¿Se puso en contacto con el servicio de soporte a través de la app en los 7 días posteriores a la creación de la billetera? Si lo hizo o no está seguro, pulse en \"Sí\" y complete las instrucciones. ¡Gracias! ¡Todo listo! No se requieren más acciones. Ahora será redirigido al sitio web oficial de Tangem. Por favor, lea y siga las instrucciones allí indicadas. ¿Alguna vez contactó al equipo de soporte de Tangem directamente desde esta aplicación? @@ -1355,11 +1357,11 @@ Solo para fines de prueba El saldo podría estar desactualizado. Actualice la página. No hay suficiente %1$s. Recargue su cuenta %2$s para asociar este token - Habilitar Trustline + Habilitar Trustline (línea de confianza) Una trustline (línea de confianza) debe estar habilitada para recibir este token. La red requiere un %1$s %2$s reserva. Se requiere trustline (línea de confianza) - La red requerida %s no está añadida a su portafolio. Añádala primero y luego continúe con la conexión. - Agregue red al portafolio + La red requerida %s no está agregada a su portafolio. Agréguela primero y luego continúe con la conexión. + Agregar red al portafolio Dominio malicioso Dominio desconocido Conectarse de todas formas diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 0d2ee7b831..2b20ddae59 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -854,7 +854,6 @@ Vérifiez votre connexion réseau Informations sur les frais de réseau inaccessibles Vous envoyez - De De %s Limite de gaz Il s\'agit du montant maximal de gaz qui sera dépensé pour effectuer une transaction ou un contrat. Une limite de gaz empêche des frais imprévus ou illimités lors de l\'exécution d\'une transaction. @@ -1011,7 +1010,7 @@ Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille. Jusqu\'à 0,2 TON peuvent être requis en plus des frais de réseau pour terminer la transaction. Tout montant non utilisé sera remboursé. 0,2 TON requis pour effectuer cette opération, en plus des frais de réseau. Veuillez recharger votre solde. - 0,2 Ton réservés + Réserve de TON requise Cette action fermera d\'autres positions ou les fera passer au statut de retrait, conformément aux règles du réseau. Statut des positions Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s. diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index ff93a67733..5205787420 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -35,6 +35,7 @@ アカウントを追加 アカウントを編集 %1$s ( %2$s内) + メインアカウント アカウントが回復しました アカウントを長押しして並べ替える 編集を続ける @@ -179,6 +180,7 @@ 許可する 金額 アナリティクス + そして 適用する 承認 承認 @@ -242,6 +244,7 @@ 終了 無料 アドレスを同期する + はじめる プロバイダーへ移動 トークンへ移動 わかりました @@ -250,6 +253,7 @@ インポート 進行中 後で + もっと詳しく 残り%1$s レガシービットコイン ロックされています @@ -387,6 +391,7 @@ デフォルトアドレス レガシーアドレス 資金を受け取る + %sアドレス 他のネットワークで資産を送金すると、永久に失われます。 %sネットワーク 下記のみを使用して資金を送金する @@ -410,7 +415,7 @@ %sによる交換 返金を受けるには、プロバイダーのウェブサイトにアクセスしてください。 プロバイダーによる操作が失敗しました。 - スワップに予想以上の時間がかかっています。プロバイダーサポートにお問い合わせください。 + スワップは通常より時間がかかっておりますが、資金は完全に安全であり、送金されます。ご不明な点がございましたら、プロバイダーのサポートチームまでお問い合わせください。 長い取引時間 OKXまたはブリッジのルールにより、取引金額は%1$sでウォレットに返金されました。%2$s 金額は %1$s(%2$sネットワーク)で返金されました @@ -495,6 +500,7 @@ 新しいウォレットを作成する Tangemを注文 Tangemをスキャン + 「Tangem」に生体認証の使用を許可しますか?\n本人確認とアプリの起動のために使用されます。 %sへ %sネットワーク アクセスコードの作成プロセスを終了してもよろしいですか? @@ -786,6 +792,7 @@ 追加しようとしているカードには、すでに別のウォレットが作成されています。このウォレットに資金がある場合は、それを引き出してからこのカードをリセットし、バックアップとして追加してください。 ウォレットを保存する バックアップの作成 + 生体認証を使用する シードフレーズについてもっと読む これらの %d 単語を以下の順番通りに書き留め、安全かつ秘密の場所に保管してください。 @@ -837,6 +844,7 @@ アクセスコードの復元 同一のカード アクセスコード + すべてのオファー %sで利用可能 プロバイダーが取引を促進します 国で検索 @@ -844,13 +852,22 @@ その他の通貨 人気の法定通貨 通貨で検索 + 即時 オンランプ機能を使用することにより、プロバイダの%1$sおよび%2$sに同意するものとします 買付金額は%s以下にしてください 買付金額は少なくとも%sである必要があります この通貨で利用可能なプロバイダーはありません + 最速 支払う + 支払方法 最大 %s まで使用可能 %s 以上で利用可能 + + %dプロバイダー + + プロバイダー + 最近使用した + 推奨 サードパーティプロバイダー%sで取引を完了できます。 %sにリダイレクトしています... この国では当社のサービスはご利用いただけません @@ -859,7 +876,13 @@ 正確なお支払い方法とサービスを確保するため、正しい国を選択してください。 設定 この画面を閉じて、トークンの詳細画面で取引状況を確認できます。 + + 最大%d日 + + %s分 + 以下が手に入ります。 この画面を閉じて、トークンの詳細画面で取引状況を確認できます。 + 最大 経由 支払い グループ @@ -970,7 +993,6 @@ ネットワーク接続を確認してください ネットワーク手数料についての情報にアクセスできません 送信する - より %sから ガス上限 これは、取引または契約を完了するために使用されるガスの最大額です。ガス上限を設定することで、取引実行時に予期せぬ請求や無制限の請求を防ぐことができます。 @@ -1014,7 +1036,7 @@ 有効なアドレスではありません トークンの損失を避けるために、受信ウォレットアドレスが** %sネットワーク**上にあることを確認してください %s に送信 - %sネットワークアドレスを確認してください。エラーが発生すると転送が失われる可能性があります。 + %sネットワーク上のアドレスに送信していることを確認してください。エラーが発生すると資金が失われる可能性があります。 送金先 メモ/宛先タグは、同じネットワーク上の同じ受信者に送信された取引を区別するためのユニークなIDです。注意:メモを省略すると、資金の行き違いにつながる可能性があります。 メモ / 宛先タグは、暗号資産ネットワーク内の共有受信者への取引を分離するコードです。 @@ -1025,7 +1047,7 @@ 送金中... 変更するには任意の箇所をタップしてください %sを送金する - **%1$s** を送金する (ネットワーク手数料%2$sを含む) + %2$sのネットワーク料を含む**%1$s**を送信しています。 **%1$s** と %2$s を送金しています。 ** %1$s ** を送信しています ネットワーク手数料は%1$sエネルギーを使用してカバーされます @@ -1127,7 +1149,7 @@ TONでステーキングを開始するには、まず任意の金額の送金を行ってください。これにより、ウォレットがアクティブになります。 取引を完了するには、ネットワーク手数料に加えて最大0.2TONが必要になる場合があります。未使用分は返金されます。 この操作を続行するには、ネットワーク手数料に加えて0.2 TONが必要です。残高を補充してください。 - 0.2 TON保留済み + TONの準備金が必要です このアクションは、ネットワークのルールに従って、他のポジションをクローズするか、引き出しステータスに切り替えます。 ポジション状況 資金をステーキングから引き出すには、ロックを解除してください。ロック解除には%sかかります。 @@ -1601,4 +1623,64 @@ いいえ、すべて送信します %s XTZを減らす 次回ウォレットにチャージするときに手数料の増加を避けるには、金額を%s XTZ減らしてください。 + 承認する + 前回の承認に問題があったため、新しい承認が必要です。手続き方法を選択してください。 + 承認が必要 + 手数料が差し引かれ、あなたの資産は再び貸し出されます。 + 引き続き収益を得るには承認が必要です。 + 承認を確定する + 資産残高 + あなたの%sはAaveに預けられています + %s%を獲得 + 利用可能 + 現在のAPY + 私の資金 + あなたの%1$sはAaveに預けられ、利息が付きます。あなたは%2$sトークンを保有しており、これは残高を表し、時間の経過とともに増加します。入金すると、資金はAaveに預け入れられ、取引手数料を差し引いた利息が付きます。 + アクティブ + 利回りを得る + 総収益 + Aaveへの送金 + 資産はプロトコル内に保管されています + Aaveの詳細を見る + あなたの%sはAaveに預けられています + これは%sの現在の供給手数料です。実際のコストは受取画面に表示されます。 + 現在の手数料 + 今後の%sの入金はすべて、取引手数料が差し引かれて自動的にAaveに供給されます。 + ネットワーク手数料が上限手数料を超えた場合、手数料が下がるまで取引は成立しません。この制限は後で変更できます。 + 最大手数料 + 手数料ポリシー + ここに説明文を入力してください。1〜3行が理想的です。 + トークン承認が必要 + アカウントへの入金はすべて自動的にAaveに貸し出されます。 + 残高は自動的に計算されます + いつでも、即座に資金を送信、交換、売却できます。 + すぐに現金化 + 使い方 + Aaveは世界中で何百万人もの人々に信頼されています。総貸付額は104億ドルです。 + 分散型・自己管理型 + サービスを使用することにより、プロバイダーに同意したものとみなされます + 年間%s%の収益 + Aave • 変動金利 + Aave + 平均%s + 昨年のリターン + 現在の金利は常に変動し、リアルタイムの需要と供給に基づいて、AAVEオンチェーンスマートコントラクトによって自動的に計算されます。 + 提供 + 金利は変動します + 入金すると、資金は自動的にAaveに送金され、利息が付き始めます。取引手数料として%s相当の少額の手数料が差し引かれます。 + 利息の獲得を開始 + %sはAaveに供給され、すぐに利用可能になります + 手数料ポリシーを見る + 次回の入金は自動的にAaveに供給されます。 + 収益を停止する + オフにすると、Aaveから資金が引き出され、ウォレットの%sに戻され、報酬の獲得が停止されます。 + 出金金額からネットワーク手数料が差し引かれます。 + APY + あなたの資産を眠らせない — 残高を運用して利息を得ましょう。 + 保有資産から報酬を得る + 入金の処理中 + 年間%1$s%%の収益 + 自動 + 取引のネットワーク手数料をカバーするために、 %1$s %2$sを入金してください + %s手数料を支払えません diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 34e2073bb1..58186b2510 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -142,6 +142,7 @@ Подтвердить Подключение Продолжить + Конвертировать Копировать Скопировать адрес Создать @@ -236,6 +237,7 @@ Отправить Успешно Поддержка + Поддерживаемые сети Обменять условия участия Условиями использования @@ -743,6 +745,7 @@ Восстановление кода доступа Идентичные карты Код доступа + Все предложения Доступно с %s Tangem предоставляет доступ к покупке через сторонних провайдеров в соответствии с их правилами Поиск по стране @@ -750,13 +753,25 @@ Другие валюты Популярные фиаты Поиск по валюте + Моментально Пользуясь сервисом покупки, вы соглашаетесь с %1$s и %2$s Сумма покупки не может быть больше, чем %s Сумма покупки должна составлять минимум %s Нет доступных провайдеров для выбранной валюты + Самый быстрый Оплата с + Платежный метод Доступно до %s Доступно от %s + + %d провайдер + %d провайдера + %d провайдеров + %d провайдеров + + Провайдеры + Ранее используемый + Рекомендуемый Вы сможете продолжить транзакцию через сервис стороннего провайдера, %s Переход к %s... Наши сервисы недоступны в данной стране @@ -765,8 +780,18 @@ Пожалуйста, укажите правильную страну, чтобы обеспечить точность доступных способов оплаты и услуг. Настройки You can close this screen and check the transaction status on the token details screen. + + до %d дня + до %d дней + до %d дней + до %d дней + + %s мин + Вы получите Вы можете закрыть этот экран и проверить статус транзакции на экране информации о токене. + До Через + Вы заплатите Группы По балансу Упорядочить токены @@ -854,6 +879,7 @@ Вы указали комиссию ниже рекомендуемой, это может привести к задержке исполнения вашей транзакции. Продолжить? Причина: %1$s\nКод: %2$s Транзакция не выполнена + Конвертировать в другой токен Сумма Вы можете установить комиссию за транзакцию, изменив значение в поле Satoshi per vByte. Комиссия, которая будет взята за вашу транзакцию. Вы можете выставить своё собственное значение. @@ -879,7 +905,7 @@ Проверьте своё интернет соединение Информация о комиссии сети недоступна Вы отправляете - Из + Из %s Лимит газа Это максимальное количество газа, которое будет потрачено на выполнение транзакции или контракта. Лимит газа предотвращает неожиданные или неограниченные расходы при выполнении транзакции. Цена газа @@ -920,6 +946,7 @@ Получатель Неверный адрес Убедитесь, что вы отправляете средства на адрес кошелька %s. Ошибки могут привести к потере ваших токенов. + Убедитесь, что отправляете на адрес в сети %s, ошибки могут привести к потере средств. Отправить Мемо/ Код назначения - это код, разделяющий транзакции к общему получателю в сети криптовалют. Внимание: отсутствие мемо может привести к потере средств. Memo / Destination Tag — это код, разделяющий транзакции, отправляемые одному и тому же получателю в криптовалютной сети. @@ -948,10 +975,13 @@ Обмен и отправка Продолжить с обменом? Это действие удалит предыдущие данные Подтвердить конвертацию + Отправка любой другой валюты приведёт к её безвозвратной потере. + Выберите правильную сеть получателя Выберите любой токен к получению. Ваш адресат получит ровно то, что вы выбрали — без лишних сложностей. Будет получено Получателю Сумма к получению + К получению %s Вы уверены, что хотите отменить конвертацию? Ваши предыдущие данные будут удалены. Убрать конвертацию Отправка с обменом @@ -1026,7 +1056,7 @@ Чтобы начать стейкинг в TON, сначала осуществите исходящую транзакцию на любую сумму — это активирует ваш кошелек. До 0.2 TON может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена. Для выполнения операции требуется дополнительно 0.2 TON, помимо сетевой комиссии. Пожалуйста, пополните баланс. - Зарезервировано 0.2 TON + Требуется резерв TON Это действие закроет другие позиции или переведёт их в статус вывода средств в соответствии с правилами сети. Статус позиций Разблокируйте свои средства, чтобы вывести их из стейкинга. Разблокировка займёт %s. @@ -1187,6 +1217,7 @@ Tangem Twin Это действие необратимо. У вас не будет доступа к старому кошельку. Приложите twin-карту с номером %s и не убирайте до окончания операции + Пожалуйста, попробуйте позже. Если проблема сохранится, обратитесь в поддержку. Произошла ошибка. Код ошибки: %s. Попробуйте, пожалуйста, снова. Если проблема будет продолжать возникать — обратитесь в службу поддержки. Используйте %s или отсканируйте карту/кольцо, чтобы получить доступ к своему кошельку Соединение не удалось: это dApp использует Wallet Connect версии 1.0, которая не поддерживается. Убедитесь, что dApp поддерживает Wallet Connect версии 2.0 для успешного подключения. diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 33db0a745b..8ef3fe2fc2 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -134,6 +134,7 @@ Підтвердити Підключення Продовжити + Конвертувати Копіювати Скопіювати адресу Створити @@ -836,6 +837,7 @@ Ви вказали комісію нижче рекомендованої, це може спричинити затримку вашої транзакції. Продовжити? Причина: %1$s\nКод: %2$s Транзакція не завершена + Конвертувати в інший токен Сума Ви можете встановити розмір комісії за транзакцію, налаштувавши значення в полі Satoshi per vByte. Комісія, яка буде стягнута за вашу транзакцію. Ви можете встановити власне значення. @@ -861,7 +863,6 @@ Перевірте підключення до мережі Інформація щодо комісії в мережі недоступна Ви надсилаєте - Із Ліміт газу Це максимальна кількість газу, яка буде витрачена на здійснення транзакції чи контракту. Ліміт газу запобігає несподіваним або необмеженим стягненням під час виконання транзакції. Вартість газу @@ -902,6 +903,7 @@ Одержувач Недійсна адреса Переконайтеся, що адреса гаманця одержувача знаходиться в мережі %s, щоб уникнути втрати ваших токенів + Переконайтеся, що ви надсилаєте на адресу в мережі %s. Помилка може призвести до втрати коштів. Надіслати до Memo/ Тег призначення — це унікальний ідентифікатор для розрізнення транзакцій, надісланих тому самому одержувачу в тій самій мережі. Застереження: відсутність тегу може призвести до втрати коштів. Мемо / Destination Tag — це код, що розділяє транзакції до спільного одержувача в криптовалютній мережі. @@ -926,7 +928,21 @@ Недопустима сума Комісія перевищує залишок Сума, що відправляється, перевищує залишок + Ви впевнені, що хочете змінити токен отримання? Це призведе до скидання раніше введених даних. + Зміна токену + Обмін та надсилання + Продовжити обмін? Це очистить ваші попередні дані. + Підтвердити конвертацію + Надсилання будь-якої іншої валюти призведе до її незворотної втрати. + Виберіть правильну мережу одержувача Виберіть будь-який токен. Той, кому ви його надішлете, отримає саме те, що ви обрали — без жодних ускладнень. + Буде отримано + Отримувачу + Сума для отримання + До отримання %s + Ви впевнені, що хочете скасувати конвертацію? Ваші попередні дані будуть видалені. + Прибрати конвертацію + Надіслати з обміном Трансакцію надіслано Підготуйтеся до сканування кільця або картки, яку ви хочете налаштувати. Забути гаманець @@ -999,7 +1015,7 @@ Щоб почати стейкінг в TON, спочатку здійсніть вихідну транзакцію на будь-яку суму — це активує ваш гаманець. До 0.2 TON може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута. Для виконання операції потребується додатково 0.2 TON, крім мережевої комісії. Будь ласка, поповніть баланс. - Зарезервовано 0.2 TON + Потрібен резерв TON Ця дія закриє інші позиції або переведе їх у статус виведення, відповідно до правил мережі. Статус позицій Розблокуйте свої кошти, щоб вивести їх зі стейкінгу. Розблокування займе %s. @@ -1159,6 +1175,7 @@ Tangem Twin Ця дія є незворотною. Ви не матимете доступу до старого гаманця. Прикладіть twin-картку з номером %s та не прибирайте її до завершення операції + Спробуйте ще раз пізніше. Якщо проблема залишиться, зверніться до служби підтримки. Виникла помилка. Код помилки: %s. Спробуйте, будь ласка, знову. Якщо проблема знову виникає — зверніться до нашої служби підтримки. Використовуйте %s або відскануйте картку/кільце, щоб отримати доступ до свого гаманця Не вдалося встановити з\'єднання: Цей dApp використовує Wallet Connect версії 1.0, яка не підтримується. Будь ласка, переконайтеся, що dApp підтримує Wallet Connect версії 2.0 для успішного підключення. @@ -1383,6 +1400,7 @@ Відомий ризик безпеки Відкрийте програму Web3 та виберіть опцію WalletConnect Запит від + Підписати все одно Тип підпису Для підключення dApp потрібна принаймні одна мережа Вкажіть вибрані мережі diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index c3d240ace5..1faf5263e0 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -249,6 +249,7 @@ Finish Free Synchronize addresses + Get started Go to provider Go to token Got it @@ -1055,7 +1056,7 @@ Not a valid address Ensure the receiving wallet address is on **the %s network** to avoid losing your tokens send to %s - Ensure you %s network address, as errors may result in lost transfers + Make sure you are sending to an address on the %s network. An error may result in the loss of funds. Send to A Memo/Destination Tag is a unique ID for differentiating transactions sent to the same recipient on the same network. Caution: Omitting a memo may lead to misplaced funds Memo / Destination Tag is a code separating transactions to a shared recipient in a crypto network. @@ -1092,7 +1093,7 @@ Recipient will receive To recipient Amount to receive - Recipient get %s + Recipient gets %s Are you sure you want to cancel the conversion? Your previous data will be cleared. Remove Conversion Send with swap @@ -1168,7 +1169,7 @@ To start staking in TON, first make an outgoing transaction of any amount — this will activate your wallet. Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. 0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance. - 0.2 Ton Reserved + TON reserve required This action will close other positions or switch them to withdrawal status, according to network rules. Positions status Unlock your money to withdraw it from staking process. Unlocking takes %s. @@ -1693,7 +1694,7 @@ To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ Give Approve Something went wrong with your previous approval, so we need a new one. Choose how you’d like to proceed. - Approve nedeed + Approve needed The fee will be taken out, and your assets will be lent again. To continue earning, approval is required. Confirm approval @@ -1704,16 +1705,12 @@ Current APY My Funds Your %1$s is now deposited in Aave and earning interest. You hold a%2$s token, which represents your balance and grows over time. When you top up, funds go to Aave to earn interest, minus a transaction fee. - Read more Active - Stop earning Earn Total earnings - Automatic Transfers to Aave Text explaining that your savings are located in the protocol Explore Aave - Explore default Your %s is deposited in Aave This is the current supply fee on %s. The live cost will be shown on the Receive Screen. Current fee @@ -1731,28 +1728,28 @@ Aave is trusted by millions worldwide. Total lended value is $10.4B. Decentralized and self-custodial By using service, you agree with provider - Maximize your savings + Earn %s% yearly Aave • Variable Interest Rate Aave Avg %s Last year returns Current interest rate is always variable and automatically computed by AAVE on-chain smart-contract based on real-time supply and demand. + Powered by Interest rate is variable When you top up, your funds will be automatically sent to Aave to start earning interest. A small fee equal to %s will be deducted to cover the transaction. - Start earning + Start earning Your %s will be supplied to Aave and will stay instantly available See fee policy - Network fee Your next deposits will be automatically supplied to Aave. - Start earning + Stop earning Turning off will withdraw your funds from Aave, return them to %s in your wallet, and stop earning rewards. The network fee will be deducted from the amount you withdraw. - Stop earning APY Make your money work — earn interest on your balance. Earning on your balance Processing your deposit - Earn %s% per year + Earn %1$s%% per year + Automatic Deposit some %1$s %2$s to cover the network fee for transactions Unable to cover %s fee diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt index cf54baee5b..7dd1e1a772 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt @@ -163,4 +163,35 @@ sealed class OnrampAnalyticsEvent( ERROR_DESCRIPTION to errorDescription, ), ) + + data class FastestBuyMethodClicked( + private val tokenSymbol: String, + private val providerName: String, + private val paymentMethod: String, + ) : OnrampAnalyticsEvent( + event = "Fastest Method Clicked", + params = mapOf( + TOKEN_PARAM to tokenSymbol, + PROVIDER to providerName, + PAYMENT_METHOD to paymentMethod, + ), + ) + + data class BestRateClicked( + private val tokenSymbol: String, + private val providerName: String, + private val paymentMethod: String, + ) : OnrampAnalyticsEvent( + event = "Best Rate Clicked", + params = mapOf( + TOKEN_PARAM to tokenSymbol, + PROVIDER to providerName, + PAYMENT_METHOD to paymentMethod, + ), + ) + + data object AllOffersClicked : OnrampAnalyticsEvent( + event = "Button - All Offers", + params = emptyMap(), + ) } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt index 4d2bbcec90..2f3739e450 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersIntents.kt @@ -1,12 +1,13 @@ package com.tangem.features.onramp.alloffers.entity import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM -interface AllOffersIntents { +internal interface AllOffersIntents { fun onPaymentMethodClicked(paymentMethodId: String) - fun onBuyClick(quote: OnrampProviderWithQuote.Data) + fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) fun onBackClicked() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt index 67878d0738..351cd1a5aa 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt @@ -1,10 +1,12 @@ package com.tangem.features.onramp.alloffers.entity import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.* import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM @@ -16,6 +18,7 @@ import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal internal class AllOffersStateFactory( + private val analyticsEventHandler: AnalyticsEventHandler, private val currentStateProvider: Provider, private val allOffersIntents: AllOffersIntents, ) { @@ -59,6 +62,7 @@ internal class AllOffersStateFactory( fun getPaymentsState(): AllOffersStateUM { return when (val currentState = currentStateProvider.invoke()) { is AllOffersStateUM.Content -> { + analyticsEventHandler.send(OnrampAnalyticsEvent.PaymentMethodsScreenOpened) currentState.copy(currentMethod = null) } AllOffersStateUM.Loading, @@ -133,12 +137,13 @@ internal class AllOffersStateFactory( }, onBuyClicked = { allOffersIntents.onBuyClick( - OnrampProviderWithQuote.Data( + quote = OnrampProviderWithQuote.Data( provider = quote.provider, paymentMethod = quote.paymentMethod, toAmount = quote.toAmount, fromAmount = quote.fromAmount, ), + onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), ) }, ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt index 19e69d6696..bd6674ad34 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -11,6 +11,7 @@ import com.tangem.features.onramp.alloffers.AllOffersComponent import com.tangem.features.onramp.alloffers.entity.AllOffersIntents import com.tangem.features.onramp.alloffers.entity.AllOffersStateFactory import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM +import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.Job @@ -30,6 +31,7 @@ internal class AllOffersModel @Inject constructor( private val stateFactory: AllOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) { AllOffersStateFactory( + analyticsEventHandler = analyticsEventHandler, currentStateProvider = Provider { state.value }, allOffersIntents = this, ) @@ -42,6 +44,8 @@ internal class AllOffersModel @Inject constructor( init { subscribeOnAllOffers() + analyticsEventHandler.send(OnrampAnalyticsEvent.PaymentMethodsScreenOpened) + analyticsEventHandler.send(OnrampAnalyticsEvent.AllOffersClicked) } fun dismiss() { @@ -51,10 +55,13 @@ internal class AllOffersModel @Inject constructor( override fun onPaymentMethodClicked(paymentMethodId: String) { val contentState = state.value as? AllOffersStateUM.Content ?: return val method = contentState.methods.firstOrNull { it.methodConfig.method.id == paymentMethodId } ?: return + analyticsEventHandler.send( + event = OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = method.methodConfig.method.name), + ) _state.update { contentState.copy(currentMethod = method) } } - override fun onBuyClick(quote: OnrampProviderWithQuote.Data) { + override fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) { analyticsEventHandler.send( OnrampAnalyticsEvent.OnBuyClick( providerName = quote.provider.info.name, @@ -62,6 +69,11 @@ internal class AllOffersModel @Inject constructor( tokenSymbol = params.cryptoCurrency.symbol, ), ) + onrampOfferAdvantagesUM.toAnalyticsEvent( + cryptoCurrencySymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethodName = quote.paymentMethod.name, + )?.let { analyticsEventHandler.send(it) } params.openRedirectPage(quote) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt index 6ed094c0cb..5654a17174 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt @@ -10,5 +10,4 @@ interface OnrampIntents { fun openProviders() fun onRefresh() fun onLinkClick(link: String) - fun onContinueClick() } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index f78daf8e79..71399a3ce4 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -289,8 +289,6 @@ internal class OnrampMainComponentModel @Inject constructor( override fun onLinkClick(link: String) = urlOpener.openUrl(link) - override fun onContinueClick() = Unit - override fun onDestroy() { modelScope.launch { clearOnrampCacheUseCase.invoke() } quotesTaskScheduler.cancelTask() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt index 5e41b49346..54ea1f1451 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt @@ -2,6 +2,7 @@ package com.tangem.features.onramp.mainv2.entity import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.OnrampPaymentMethod import kotlinx.collections.immutable.ImmutableList @@ -43,7 +44,27 @@ internal enum class OnrampOfferCategoryUM { } internal enum class OnrampOfferAdvantagesUM { - Default, BestRate, Fastest + Default, BestRate, Fastest; + + fun toAnalyticsEvent( + cryptoCurrencySymbol: String, + providerName: String, + paymentMethodName: String, + ): OnrampAnalyticsEvent? { + return when (this) { + BestRate -> OnrampAnalyticsEvent.BestRateClicked( + tokenSymbol = cryptoCurrencySymbol, + providerName = providerName, + paymentMethod = paymentMethodName, + ) + Fastest -> OnrampAnalyticsEvent.FastestBuyMethodClicked( + tokenSymbol = cryptoCurrencySymbol, + providerName = providerName, + paymentMethod = paymentMethodName, + ) + Default -> null + } + } } internal data class OnrampAllOffersButtonConfig( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt new file mode 100644 index 0000000000..a9f65922cd --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt @@ -0,0 +1,13 @@ +package com.tangem.features.onramp.mainv2.entity + +import com.tangem.domain.onramp.model.OnrampProviderWithQuote + +internal interface OnrampV2Intents { + fun onAmountValueChanged(value: String) + fun openSettings() + fun openCurrenciesList() + fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) + fun openProviders() + fun onRefresh() + fun onContinueClick() +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt index e0947e34b5..ec94be047d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt @@ -8,7 +8,6 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.onramp.main.entity.OnrampIntents import com.tangem.features.onramp.mainv2.entity.* import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory import com.tangem.utils.Provider @@ -18,7 +17,7 @@ import java.math.BigDecimal internal class OnrampV2AmountFieldChangeConverter( private val currentStateProvider: Provider, private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, - private val onrampIntents: OnrampIntents, + private val onrampIntents: OnrampV2Intents, private val cryptoCurrency: CryptoCurrency, ) : Converter { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt index 377c5987d4..966893cab7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt @@ -7,7 +7,6 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.onramp.model.* import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.main.entity.OnrampIntents import com.tangem.features.onramp.mainv2.entity.* import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.MINUS @@ -15,7 +14,7 @@ import kotlinx.collections.immutable.toPersistentList internal class OnrampOffersStateFactory( private val currentStateProvider: Provider, - private val onrampIntents: OnrampIntents, + private val onrampIntents: OnrampV2Intents, ) { fun getOnShowOffersState(offers: List): OnrampV2MainComponentUM { @@ -65,6 +64,7 @@ internal class OnrampOffersStateFactory( toAmount = currentQuote.toAmount, fromAmount = currentQuote.fromAmount, ), + onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), ) }, ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt index 3c7a477fd1..0e1dc10b5b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt @@ -15,7 +15,6 @@ import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.tokens.model.AmountType import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.main.entity.OnrampIntents import com.tangem.features.onramp.mainv2.entity.* import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter import com.tangem.utils.Provider @@ -24,7 +23,7 @@ import java.math.BigDecimal internal class OnrampV2AmountStateFactory( private val currentStateProvider: Provider, private val analyticsEventHandler: AnalyticsEventHandler, - private val onrampIntents: OnrampIntents, + private val onrampIntents: OnrampV2Intents, private val cryptoCurrency: CryptoCurrency, private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, ) { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt index 8e37235b44..e39a2078b2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt @@ -20,7 +20,6 @@ import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.tokens.model.convertToAmount import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.main.entity.OnrampIntents import com.tangem.features.onramp.mainv2.entity.* import com.tangem.utils.Provider import java.math.BigDecimal @@ -29,7 +28,7 @@ internal class OnrampV2StateFactory( private val currentStateProvider: Provider, private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, private val cryptoCurrency: CryptoCurrency, - private val onrampIntents: OnrampIntents, + private val onrampIntents: OnrampV2Intents, ) { fun getInitialState( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt index abead667d7..57cdbcf6b6 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -15,7 +15,6 @@ import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.onramp.main.entity.OnrampIntents import com.tangem.features.onramp.main.entity.OnrampLastUpdate import com.tangem.features.onramp.mainv2.OnrampV2MainComponent import com.tangem.features.onramp.mainv2.entity.* @@ -48,7 +47,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( private val getOnrampOffersUseCase: GetOnrampOffersUseCase, paramsContainer: ParamsContainer, getWalletsUseCase: GetWalletsUseCase, -) : Model(), OnrampIntents { +) : Model(), OnrampV2Intents { val params = paramsContainer.require() @@ -126,7 +125,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList) } - override fun onBuyClick(quote: OnrampProviderWithQuote.Data) { + override fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) { val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return analyticsEventHandler.send( OnrampAnalyticsEvent.OnBuyClick( @@ -135,6 +134,11 @@ internal class OnrampV2MainComponentModel @Inject constructor( tokenSymbol = params.cryptoCurrency.symbol, ), ) + onrampOfferAdvantagesUM.toAnalyticsEvent( + cryptoCurrencySymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethodName = quote.paymentMethod.name, + )?.let { analyticsEventHandler.send(it) } params.openRedirectPage(quote) } @@ -159,10 +163,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( } } - override fun onLinkClick(link: String) { - // TODO in [REDACTED_TASK_KEY] will be deleted - } - override fun onContinueClick() { val currentState = _state.value if (currentState is OnrampV2MainComponentUM.Content) { From a8f084ae8835064f0dd6f16adcfda9e785195044 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Sep 2025 11:38:08 +0200 Subject: [PATCH 066/132] Updated on 2026-08-14 --- .../tangem/features/onramp/alloffers/model/AllOffersModel.kt | 2 +- .../features/onramp/mainv2/model/OnrampV2MainComponentModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt index bd6674ad34..29414b13c0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -73,7 +73,7 @@ internal class AllOffersModel @Inject constructor( cryptoCurrencySymbol = params.cryptoCurrency.symbol, providerName = quote.provider.info.name, paymentMethodName = quote.paymentMethod.name, - )?.let { analyticsEventHandler.send(it) } + )?.let { analyticsEventHandler::send } params.openRedirectPage(quote) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt index 57cdbcf6b6..93f199fc85 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -138,7 +138,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( cryptoCurrencySymbol = params.cryptoCurrency.symbol, providerName = quote.provider.info.name, paymentMethodName = quote.paymentMethod.name, - )?.let { analyticsEventHandler.send(it) } + )?.let { analyticsEventHandler::send } params.openRedirectPage(quote) } From dec40f34731273b3b3dd1a41c4d3ed4785d2e770 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 10:54:56 +0000 Subject: [PATCH 067/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 2572f02b34..4829355867 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1218" +tangemBlockchainSdk = "develop-1216" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-527" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 4d5b9b87e6fbfb431e31589a600e9bf5d3ede78e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 8 Sep 2025 17:12:51 +0400 Subject: [PATCH 068/132] Updated on 2026-08-14 --- .../token/DefaultUserTokensResponseStore.kt | 8 ++++ .../local/token/UserTokensResponseStore.kt | 2 + .../GetWalletAccountsResponseConverter.kt | 4 +- .../FetchWalletAccountsErrorHandler.kt | 7 ++-- .../DefaultAccountsCRUDRepository.kt | 5 +-- .../GetWalletAccountsResponseConverterTest.kt | 1 - .../FetchWalletAccountsErrorHandlerTest.kt | 2 +- .../DefaultAccountsCRUDRepositoryTest.kt | 6 +-- .../common/cache/etag/DefaultETagsStore.kt | 40 +++++++++++++++++++ .../data/common/cache/etag/ETagsStore.kt | 34 ++++++++++++++++ .../UserTokensResponseAccountIdEnricher.kt | 4 +- .../tangem/data/common/di/DataCommonModule.kt | 9 +++++ 12 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt create mode 100644 data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt index 0a80baa680..89f266e889 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserTokensResponseStore.kt @@ -39,5 +39,13 @@ internal class DefaultUserTokensResponseStore( ) } + override suspend fun clear(userWalletId: UserWalletId) { + appPreferencesStore.updateData { preferences -> + val key = createPreferencesKey(userWalletId = userWalletId.stringValue) + + preferences.toMutablePreferences().apply { remove(key) } + } + } + private fun createPreferencesKey(userWalletId: String) = stringPreferencesKey(name = "user_tokens_$userWalletId") } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt index 9631d25c78..9da0c72837 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserTokensResponseStore.kt @@ -17,4 +17,6 @@ interface UserTokensResponseStore { suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse? suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse) + + suspend fun clear(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt index 4ae6bf8b41..3f741ba0b5 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt @@ -14,7 +14,6 @@ import dagger.assisted.AssistedInject */ internal class GetWalletAccountsResponseConverter @AssistedInject constructor( @Assisted private val userWallet: UserWallet, - @Assisted val version: Int, cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory, ) : Converter { @@ -25,7 +24,6 @@ internal class GetWalletAccountsResponseConverter @AssistedInject constructor( override fun convert(value: AccountList): GetWalletAccountsResponse { return GetWalletAccountsResponse( wallet = GetWalletAccountsResponse.Wallet( - version = version, group = TokensGroupTypeConverter.convertBack(value.groupType), sort = TokensSortTypeConverter.convertBack(value.sortType), totalAccounts = value.totalAccounts, @@ -39,6 +37,6 @@ internal class GetWalletAccountsResponseConverter @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(userWallet: UserWallet, version: Int): GetWalletAccountsResponseConverter + fun create(userWallet: UserWallet): GetWalletAccountsResponseConverter } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 7142520d72..520868e3f6 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -104,9 +104,10 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( ): UserTokensResponse? { if (this != null) return this - return userTokensResponseStore.getSyncOrNull( - userWalletId = userWalletProvider().walletId, - ) + val userWalletId = userWalletProvider().walletId + + return userTokensResponseStore.getSyncOrNull(userWalletId) + .also { userTokensResponseStore.clear(userWalletId) } } private fun UserTokensResponse?.orDefault(userWalletProvider: Provider): UserTokensResponse { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index 3d148930d5..333001ed0e 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -112,10 +112,7 @@ internal class DefaultAccountsCRUDRepository( .getOrThrow() } - val converter = convertersContainer.getWalletAccountsResponseCF.create( - userWallet = accountList.userWallet, - version = version, - ) + val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet) val accountsResponse = converter.convert(value = accountList) diff --git a/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt index b6dddb7aa5..766544b58a 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt @@ -26,7 +26,6 @@ class GetWalletAccountsResponseConverterTest { private val cryptoPortfolioConverter = mockk() private val converter = GetWalletAccountsResponseConverter( userWallet = userWallet, - version = 0, cryptoPortfolioConverterFactory = cryptoPortfolioConverterFactory, ) diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index 5d41955371..532b33975b 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -30,7 +30,7 @@ class FetchWalletAccountsErrorHandlerTest { private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val userWalletsStore: UserWalletsStore = mockk() - private val userTokensResponseStore: UserTokensResponseStore = mockk() + private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk() private val cryptoPortfolioConverter = mockk() private val userTokensResponseFactory: UserTokensResponseFactory = mockk() diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt index 0002eb553d..8fbcf92aea 100644 --- a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -595,7 +595,7 @@ class DefaultAccountsCRUDRepositoryTest { } every { - convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet, version = version) + convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) } returns converter coEvery { accountsResponseStore.updateData(transform = any()) } returns accountsResponse @@ -610,7 +610,7 @@ class DefaultAccountsCRUDRepositoryTest { accountsResponseStoreFactory.create(userWalletId) accountsResponseStore.data tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body) - convertersContainer.getWalletAccountsResponseCF.create(userWallet, version) + convertersContainer.getWalletAccountsResponseCF.create(userWallet) converter.convert(accountList) accountsResponseStore.updateData(any()) } @@ -656,7 +656,7 @@ class DefaultAccountsCRUDRepositoryTest { } coVerify(inverse = true) { - convertersContainer.getWalletAccountsResponseCF.create(any(), any()) + convertersContainer.getWalletAccountsResponseCF.create(any()) accountsResponseStore.updateData(any()) } } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt new file mode 100644 index 0000000000..5c8a9b2dc1 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt @@ -0,0 +1,40 @@ +package com.tangem.data.common.cache.etag + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.datasource.local.preferences.utils.store +import com.tangem.domain.models.wallet.UserWalletId +import timber.log.Timber + +/** + * Default implementation of the [ETagsStore] interface for managing ETag values + * + * @property appPreferencesStore the preferences store used for saving and retrieving ETag values + */ +internal class DefaultETagsStore( + private val appPreferencesStore: AppPreferencesStore, +) : ETagsStore { + + override suspend fun getSyncOrNull(userWalletId: UserWalletId, key: ETagsStore.Key): String? { + val key = getAccountsETagKey(userWalletId = userWalletId, key = key) + + return appPreferencesStore.getSyncOrNull(key = key) + } + + override suspend fun store(userWalletId: UserWalletId, key: ETagsStore.Key, value: String) { + if (value.isBlank()) { + Timber.e("ETag value is blank, not storing it. userWalletId: $userWalletId, key: $key") + return + } + + val key = getAccountsETagKey(userWalletId = userWalletId, key = key) + + appPreferencesStore.store(key = key, value = value) + } + + private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key { + return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}") + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt new file mode 100644 index 0000000000..430e9a0290 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt @@ -0,0 +1,34 @@ +package com.tangem.data.common.cache.etag + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Interface for working with ETag (Entity Tag), which is used for data caching and validation. + * +[REDACTED_AUTHOR] + */ +interface ETagsStore { + + /** + * Retrieves the stored ETag value for the specified wallet and key + * + * @param userWalletId identifier of the user wallet + * @param key the key for which to get the ETag value + */ + suspend fun getSyncOrNull(userWalletId: UserWalletId, key: Key): String? + + /** + * Stores the ETag value for the specified wallet and key + * + * @param userWalletId identifier of the user wallet + * @param key the key for which to get the ETag value + */ + suspend fun store(userWalletId: UserWalletId, key: Key, value: String) + + /** Enumeration of possible keys for storing ETag values */ + enum class Key { + WalletAccounts, + UserTokens, + ; + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt index f91e9f40f3..3594f6f1a0 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt @@ -50,9 +50,9 @@ object UserTokensResponseAccountIdEnricher { if (enrichedTokens.isEmpty()) return tokens + val enrichedTokenMap = enrichedTokens.associateBy { it } return tokens.map { token -> - val enrichedToken = enrichedTokens.firstOrNull { it == token } - enrichedToken ?: token + enrichedTokenMap[token] ?: token } } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 9b18e1b8fd..79af422911 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -1,10 +1,13 @@ package com.tangem.data.common.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.cache.etag.DefaultETagsStore +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.* import com.tangem.data.common.quote.DefaultQuotesFetcher import com.tangem.data.common.quote.QuotesFetcher import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -76,4 +79,10 @@ internal object DataCommonModule { fun provideQuotesFetcher(tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider): QuotesFetcher { return DefaultQuotesFetcher(tangemTechApi = tangemTechApi, dispatchers = dispatchers) } + + @Provides + @Singleton + fun provideETagsStore(appPreferencesStore: AppPreferencesStore): ETagsStore { + return DefaultETagsStore(appPreferencesStore = appPreferencesStore) + } } \ No newline at end of file From a163569c864333300a4926547b6fc36303e834ea Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 15:40:08 +0300 Subject: [PATCH 069/132] Updated on 2026-08-14 --- .../com/tangem/scenarios/BaseScenarios.kt | 14 +- .../screens/BuyTokenDetailsPageObject.kt | 2 +- .../FailedCardVerificationDialogPageObject.kt | 28 ++ .../tangem/screens/MainScreenPageObject.kt | 24 ++ .../com/tangem/screens/SwapTokenPageObject.kt | 2 +- .../kotlin/com/tangem/tests/WarningTest.kt | 54 ++++ .../sdk/mocks/content/DevWalletMockContent.kt | 270 ++++++++++++++++++ .../components/notifications/Notification.kt | 9 +- .../core/ui/test/NotificationTestTags.kt | 4 +- 9 files changed, 400 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/FailedCardVerificationDialogPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 8355c58267..9c51a15aa5 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -4,13 +4,25 @@ import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType import com.tangem.screens.* +import com.tangem.screens.onDisclaimerScreen +import com.tangem.screens.onMainScreen +import com.tangem.screens.onMarketsTooltipScreen +import com.tangem.screens.onStoriesScreen +import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockProvider import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.openMainScreen(productType: ProductType? = null, alreadyActivatedDialogIsShown : Boolean = false) { +fun BaseTestCase.openMainScreen( + productType: ProductType? = null, + mockContent: MockContent? = null, + alreadyActivatedDialogIsShown : Boolean = false +) { if (productType != null) { MockProvider.setMocks(productType) } + if (mockContent != null) { + MockProvider.setMocks(mockContent) + } step("Click on 'Accept' button") { onDisclaimerScreen { acceptButton.clickWithAssertion() } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt index 4193f3ab8b..2b4e330704 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt @@ -38,7 +38,7 @@ class BuyTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProv } val errorNotificationText: KNode = child { - hasTestTag(NotificationTestTags.TEXT) + hasTestTag(NotificationTestTags.MESSAGE) hasText(getResourceString(R.string.common_unknown_error)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/FailedCardVerificationDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/FailedCardVerificationDialogPageObject.kt new file mode 100644 index 0000000000..a5ef4d40fb --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/FailedCardVerificationDialogPageObject.kt @@ -0,0 +1,28 @@ +package com.tangem.screens + +import com.kaspersky.kaspresso.screens.KScreen +import com.tangem.core.ui.R +import io.github.kakaocup.kakao.text.KButton +import io.github.kakaocup.kakao.text.KTextView + +object FailedCardVerificationDialogPageObject : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + val title = KTextView { + withId(R.id.alertTitle) + } + + val message = KTextView { + withId(R.id.message) + } + + val cancelButton = KButton { + withText(R.string.common_cancel) + } + + val understandButton = KButton { + withText(R.string.common_understand) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 59419f5f41..43ad397162 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -8,6 +8,7 @@ import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.utils.LazyListItemNode import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.feature.wallet.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen @@ -48,6 +49,29 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_buy)) } + val notificationContainer: KNode = child { + hasTestTag(NotificationTestTags.CONTAINER) + useUnmergedTree = true + } + + val devCardNotificationIcon: KNode = child { + hasAnySibling(withText(getResourceString(R.string.warning_developer_card_title))) + hasTestTag(NotificationTestTags.ICON) + useUnmergedTree = true + } + + val devCardNotificationTitle: KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_developer_card_title)) + useUnmergedTree = true + } + + val devCardNotificationMessage: KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(getResourceString(R.string.warning_developer_card_message)) + useUnmergedTree = true + } + /** * Find token list item with title and address */ diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index dd53bc1dc5..d069f7abe1 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -57,7 +57,7 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } val errorNotificationText: KNode = child { - hasTestTag(NotificationTestTags.TEXT) + hasTestTag(NotificationTestTags.MESSAGE) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt new file mode 100644 index 0000000000..e3c3937047 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt @@ -0,0 +1,54 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.scenarios.openMainScreen +import com.tangem.screens.onMainScreen +import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class WarningTest : BaseTestCase() { + + @AllureId("898") + @DisplayName("Warnings: check 'Dev card' warning") + @Test + fun devCardWarningTest() { + setupHooks().run { + step("Open 'Main' screen") { + openMainScreen(mockContent = DevWalletMockContent) + } + step("Assert 'Dev card' notification title is displayed") { + onMainScreen { devCardNotificationTitle.assertIsDisplayed() } + } + step("Assert 'Dev card' notification message is displayed") { + onMainScreen { devCardNotificationMessage.assertIsDisplayed() } + } + step("Assert 'Dev card' notification icon is displayed") { + onMainScreen { devCardNotificationIcon.assertIsDisplayed() } + } + } + } + + @AllureId("3991") + @DisplayName("Warnings: check 'Dev card' warning is not displayed for release card") + @Test + fun releaseCardWarningTest() { + setupHooks().run { + step("Open 'Main' screen") { + openMainScreen() + } + step("Assert 'Dev card' notification title is not displayed") { + onMainScreen { devCardNotificationTitle.assertIsNotDisplayed() } + } + step("Assert 'Dev card' notification message is not displayed") { + onMainScreen { devCardNotificationMessage.assertIsNotDisplayed() } + } + step("Assert 'Dev card' notification icon is not displayed") { + onMainScreen { devCardNotificationIcon.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt new file mode 100644 index 0000000000..29b0ec5fee --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt @@ -0,0 +1,270 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object DevWalletMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + linkingKey = byteArrayOf( + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 3, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf(EllipticCurve.Secp256k1, EllipticCurve.Ed25519, EllipticCurve.Bls12381G2Aug), + firmwareVersion = FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Sdk, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + firmwareVersion = CardDTO.FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Sdk, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1649635200000), + signature = byteArrayOf(-20, 98, -101, 94, -23, 73, 122, -21, 74, 76, 79, -55, -102, -62, -30, 44, -38, 118, 75, 121, -36, 118, -62, 60, -38, -63, 33, -14, -98, -69, 112, 22, 48, -43, 47, 65, -61, -56, 38, -94, -45, 44, 95, -22, 6, -31, -40, -25, 42, 38, 94, -99, 71, 98, -33, 27, -102, 30, 52, -7, 51, -27, 84, 66), + ), + issuer = CardDTO.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Secp256r1, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = false, + derivedKeys = mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = false, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.NoBackup, + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(3, 2, 95, 53, 40, -87, -60, 11, -8, -47, 41, 37, 100, 15, -69, 1, -122, 127, -20, -81, -32, -20, -24, 5, -28, 113, 106, -90, -59, -30, -27, -110, -110), + chainCode = byteArrayOf(-95, -87, -95, -25, 27, 96, -57, -92, -69, -106, -45, 10, 85, 4, -92, -68, 49, -24, -28, -50, -49, -77, -20, 118, -50, -27, 104, -93, 115, -50, -46, -34), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AC05000000086747") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index a3e97923bd..93f5aefdf0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -116,7 +116,9 @@ internal fun NotificationBaseContainer( ) { Box { Column( - modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), + modifier = Modifier + .padding(all = TangemTheme.dimens.spacing12) + .testTag(NotificationTestTags.CONTAINER), verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), ) { content() @@ -151,7 +153,8 @@ private fun MainContent( tint = iconTint, modifier = Modifier .size(size = iconSize) - .align(alignment = Alignment.Top), + .align(alignment = Alignment.Top) + .testTag(NotificationTestTags.ICON), ) SpacerW(width = TangemTheme.dimens.spacing10) @@ -219,7 +222,7 @@ internal fun TextsBlock( text = subtitleText, color = subtitleColor, style = TangemTheme.typography.caption2, - modifier = Modifier.testTag(NotificationTestTags.TEXT), + modifier = Modifier.testTag(NotificationTestTags.MESSAGE), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt index 15a63e2186..b02a22b08f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/NotificationTestTags.kt @@ -1,6 +1,8 @@ package com.tangem.core.ui.test object NotificationTestTags { + const val CONTAINER = "NOTIFICATION_CONTAINER" const val TITLE = "NOTIFICATION_TITLE" - const val TEXT = "NOTIFICATION_TEXT" + const val MESSAGE = "NOTIFICATION_MESSAGE" + const val ICON = "NOTIFICATION_ICON" } \ No newline at end of file From a253674fe66b8d9027e297cbaa4bab75c364e7cd Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 15:40:37 +0300 Subject: [PATCH 070/132] Updated on 2026-08-14 --- .../com/tangem/scenarios/BaseScenarios.kt | 12 + .../scenarios/DeviceSetingsScenarios.kt | 21 ++ .../tangem/scenarios/ResetCardScenarios.kt | 80 ++++++ .../screens/DeviceSettingsPageObject.kt | 61 ++++ .../com/tangem/screens/ResetCardPageObject.kt | 65 +++++ .../screens/WalletSettingsPageObject.kt | 2 +- .../kotlin/com/tangem/tests/DetailsTest.kt | 6 +- .../kotlin/com/tangem/tests/ResetCardTest.kt | 85 ++++++ .../mocks/content/BackupWalletMockContent.kt | 270 ++++++++++++++++++ .../ui/cardsettings/CardSettingsScreen.kt | 11 +- .../details/ui/resetcard/ResetCardScreen.kt | 16 +- .../ui/test/DeviceSettingsScreenTestTags.kt | 8 + .../core/ui/test/ResetCardScreenTestTags.kt | 10 + 13 files changed, 639 insertions(+), 8 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/ResetCardScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/ResetCardPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/DeviceSettingsScreenTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/ResetCardScreenTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 9c51a15aa5..272707ea7c 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -49,4 +49,16 @@ fun BaseTestCase.synchronizeAddresses(balance: String) { step("Assert wallet balance = '$balance'") { onMainScreen { walletBalance().assertTextContains(balance) } } +} + +fun BaseTestCase.openDeviceSettingsScreen() { + step("Open wallet details") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings' screen") { + onDetailsScreen { walletNameButton.performClick() } + } + step("Click on 'Device settings' button") { + onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt new file mode 100644 index 0000000000..d58f6ba8e3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt @@ -0,0 +1,21 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.onDeviceSettingsScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openResetCardScreen(withBackup: Boolean = false) { + step("Click on 'Scan card or ring' button") { + onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() } + } + step("Assert 'Reset to Factory Settings' button title is displayed") { + onDeviceSettingsScreen { resetToFactorySettingsButtonTitle.assertIsDisplayed() } + } + step("Assert 'Reset to Factory Settings' button subtitle is displayed") { + onDeviceSettingsScreen { resetToFactorySettingsButtonSubtitle(withBackup).assertIsDisplayed() } + } + step("Click on 'Reset to Factory Settings' button") { + onDeviceSettingsScreen { resetToFactorySettingsButtonTitle.performClick() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/ResetCardScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/ResetCardScenarios.kt new file mode 100644 index 0000000000..670cfbce63 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/ResetCardScenarios.kt @@ -0,0 +1,80 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onResetCardScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkResetCardScreen(withBackup: Boolean = false) { + step("Assert title is displayed") { + onResetCardScreen { title.assertIsDisplayed() } + } + step("Assert 'Attention' image is displayed") { + onResetCardScreen { attentionImage.assertIsDisplayed() } + } + step("Assert 'Attention' subtitle is displayed") { + onResetCardScreen { subtitle.assertIsDisplayed() } + } + step("Assert description is displayed") { + onResetCardScreen { description.assertIsDisplayed() } + } + step("Assert 'Lost wallet' checkbox is displayed") { + onResetCardScreen { lostWalletAccessCheckBox.assertIsDisplayed() } + } + if (withBackup) { + step("Assert 'Lost password restore' checkbox is displayed") { + onResetCardScreen { lostPasswordRestoreCheckBox.assertIsDisplayed() } + } + } else { + step("Assert 'Lost password restore' checkbox doesn't exist") { + onResetCardScreen { lostPasswordRestoreCheckBox.assertDoesNotExist() } + } + } +} + +fun BaseTestCase.checkCheckBoxLogic(withBackup: Boolean = false) { + if (withBackup) { + step("Click on 'Lost wallet' checkbox") { + onResetCardScreen { lostWalletAccessCheckBox.performClick() } + } + step("Assert 'Lost wallet' checkbox is enabled") { + onResetCardScreen { lostWalletAccessCheckBox.assertIsEnabled() } + } + step("Assert 'Reset the card' button is disabled") { + onResetCardScreen { resetCardButton.assertIsNotEnabled() } + } + step("Click on 'Lost password restore' checkbox") { + onResetCardScreen { lostPasswordRestoreCheckBox.performClick() } + } + step("Assert 'Lost password restore' checkbox is enabled") { + onResetCardScreen { lostPasswordRestoreCheckBox.assertIsEnabled() } + } + step("Assert 'Reset the card' button is enabled") { + onResetCardScreen { resetCardButton.assertIsEnabled() } + } + step("Click on 'Lost password restore' checkbox") { + onResetCardScreen { lostPasswordRestoreCheckBox.performClick() } + } + step("Assert 'Reset the card' button is disabled") { + onResetCardScreen { resetCardButton.assertIsNotEnabled() } + } + step("Click on 'Lost wallet' checkbox") { + onResetCardScreen { lostWalletAccessCheckBox.performClick() } + } + step("Click on 'Lost password restore' checkbox") { + onResetCardScreen { lostPasswordRestoreCheckBox.performClick() } + } + step("Assert 'Reset the card' button is disabled") { + onResetCardScreen { resetCardButton.assertIsNotEnabled() } + } + } else { + step("Click on 'Lost wallet' checkbox") { + onResetCardScreen { lostWalletAccessCheckBox.performClick() } + } + step("Assert 'Lost wallet' checkbox is enabled") { + onResetCardScreen { lostWalletAccessCheckBox.assertIsEnabled() } + } + step("Assert 'Reset the card' button is enabled") { + onResetCardScreen { resetCardButton.assertIsEnabled() } + } + } +} diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt new file mode 100644 index 0000000000..7822f4b34e --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt @@ -0,0 +1,61 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.DeviceSettingsScreenTestTags +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + private val lazyList = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(DeviceSettingsScreenTestTags.LAZY_LIST) }, + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> + SemanticsMatcher.expectValue( + LazyListItemPositionSemantics, + position + ) + } + ) + + val imageBlock: KNode = child { + hasTestTag(DeviceSettingsScreenTestTags.IMAGE_BLOCK) + useUnmergedTree = true + } + + val resetToFactorySettingsButtonTitle: KNode = child { + hasTestTag(DeviceSettingsScreenTestTags.ITEM_TITLE) + hasText(getResourceString(R.string.card_settings_reset_card_to_factory)) + useUnmergedTree = true + } + + val scanCardOrRingButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.scan_card_settings_button)) + useUnmergedTree = true + } + + fun resetToFactorySettingsButtonSubtitle(withBackup: Boolean = false): KNode = child { + hasTestTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE) + useUnmergedTree = true + if (withBackup) { + hasText(getResourceString(R.string.reset_card_with_backup_to_factory_message)) + } else { + hasText(getResourceString(R.string.reset_card_without_backup_to_factory_message)) + } + } +} + +internal fun BaseTestCase.onDeviceSettingsScreen(function: DeviceSettingsPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ResetCardPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ResetCardPageObject.kt new file mode 100644 index 0000000000..9bbb4eccce --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ResetCardPageObject.kt @@ -0,0 +1,65 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.ResetCardScreenTestTags +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText + +class ResetCardPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(ResetCardScreenTestTags.TITLE) + hasText(getResourceString(R.string.card_settings_reset_card_to_factory)) + useUnmergedTree = true + } + + val attentionImage: KNode = child { + hasTestTag(ResetCardScreenTestTags.ATTENTION_IMAGE) + useUnmergedTree = true + } + + val subtitle: KNode = child { + hasTestTag(ResetCardScreenTestTags.SUBTITLE) + hasText(getResourceString(R.string.common_attention)) + useUnmergedTree = true + } + + val description: KNode = child { + hasTestTag(ResetCardScreenTestTags.DESCRIPTION) + useUnmergedTree = true + } + + val lostWalletAccessCheckBox: KNode = child { + hasTestTag(ResetCardScreenTestTags.CHECKBOX) + hasAnySibling( + withTestTag(ResetCardScreenTestTags.CHECKBOX_TEXT) and + withText(getResourceString(R.string.reset_card_to_factory_condition_1)) + ) + useUnmergedTree = true + } + + val lostPasswordRestoreCheckBox: KNode = child { + hasTestTag(ResetCardScreenTestTags.CHECKBOX) + hasAnySibling( + withTestTag(ResetCardScreenTestTags.CHECKBOX_TEXT) and + withText(getResourceString(R.string.reset_card_to_factory_condition_2)) + ) + useUnmergedTree = true + } + + val resetCardButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.reset_card_to_factory_button_title)) + } +} + +internal fun BaseTestCase.onResetCardScreen(function: ResetCardPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt index 6a2a606ef5..e8b554846b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt @@ -21,7 +21,7 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi val linkMoreCardsButton: KNode = walletSettingsItem.child { hasText(getResourceString(R.string.details_row_title_create_backup)) } - val cardSettingsButton: KNode = walletSettingsItem.child { + val deviceSettingsButton: KNode = walletSettingsItem.child { hasText(getResourceString(R.string.card_settings_title)) } val referralProgramButton: KNode = walletSettingsItem.child { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt index b60639cf4a..49fe556eba 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt @@ -55,7 +55,7 @@ class DetailsTest : BaseTestCase() { linkMoreCardsButton.assertIsDisplayed() } step("Assert 'Card Settings' button is visible") { - cardSettingsButton.assertIsDisplayed() + deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button is visible") { referralProgramButton.assertIsDisplayed() @@ -105,7 +105,7 @@ class DetailsTest : BaseTestCase() { linkMoreCardsButton.assertIsNotDisplayed() } step("Assert 'Card Settings' button is visible") { - cardSettingsButton.assertIsDisplayed() + deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button is visible") { referralProgramButton.assertIsDisplayed() @@ -152,7 +152,7 @@ class DetailsTest : BaseTestCase() { } onWalletSettingsScreen { step("Assert 'Card Settings' button is visible") { - cardSettingsButton.assertIsDisplayed() + deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button does not exist") { referralProgramButton.assertIsNotDisplayed() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt new file mode 100644 index 0000000000..d159dc0eda --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt @@ -0,0 +1,85 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.* +import com.tangem.tap.domain.sdk.mocks.content.BackupWalletMockContent +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore +import org.junit.Test + +@HiltAndroidTest +class ResetCardTest : BaseTestCase() { + + @AllureId("3988") + @DisplayName("Reset card: reset Wallet 1.0 card without backup") + @Test + fun resetWallet1CardWithoutBackupTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Open 'Reset card' screen") { + openResetCardScreen() + } + step("Check 'Reset card' screen") { + checkResetCardScreen() + } + step("Check checkbox logic") { + checkCheckBoxLogic() + } + } + } + + @AllureId("3987") + @DisplayName("Reset card: reset Wallet 1.0 card with backup") + @Test + fun resetWallet1CardWithBackupTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(mockContent = BackupWalletMockContent) + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Open 'Reset card' screen") { + openResetCardScreen(withBackup = true) + } + step("Check 'Reset card' screen") { + checkResetCardScreen(withBackup = true) + } + step("Check checkbox logic") { + checkCheckBoxLogic(withBackup = true) + } + } + } + + @AllureId("3974") + @DisplayName("Reset card: reset Wallet 2.0 card with backup") + @Ignore("toDo [REDACTED_TASK_KEY]: On CI Already used wallet dialog doesn't displayed") + @Test + fun resetWallet2CardWithBackupTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(productType = ProductType.Wallet2, alreadyActivatedDialogIsShown = true) + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Open 'Reset card' screen") { + openResetCardScreen(withBackup = true) + } + step("Check 'Reset card' screen") { + checkResetCardScreen(withBackup = true) + } + step("Check checkbox logic") { + checkCheckBoxLogic(withBackup = true) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt new file mode 100644 index 0000000000..c2c8d9ba23 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt @@ -0,0 +1,270 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object BackupWalletMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + linkingKey = byteArrayOf( + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 3, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf(EllipticCurve.Secp256k1, EllipticCurve.Ed25519, EllipticCurve.Bls12381G2Aug), + firmwareVersion = FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AC05000000086747", + batchId = "AC05", + cardPublicKey = byteArrayOf(2, -120, -3, -32, -122, -127, -120, -104, 59, 72, 76, 114, 94, 75, -37, -55, 55, 99, 66, 123, 85, -87, 80, 106, 105, -116, 87, -83, -12, 70, 108, -68, -39), + firmwareVersion = CardDTO.FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1649635200000), + signature = byteArrayOf(-20, 98, -101, 94, -23, 73, 122, -21, 74, 76, 79, -55, -102, -62, -30, 44, -38, 118, 75, 121, -36, 118, -62, 60, -38, -63, 33, -14, -98, -69, 112, 22, 48, -43, 47, 65, -61, -56, 38, -94, -45, 44, 95, -22, 6, -31, -40, -25, 42, 38, 94, -99, 71, 98, -33, 27, -102, 30, 52, -7, 51, -27, 84, 66), + ), + issuer = CardDTO.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Secp256r1, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(3, 2, 95, 53, 40, -87, -60, 11, -8, -47, 41, 37, 100, 15, -69, 1, -122, 127, -20, -81, -32, -20, -24, 5, -28, 113, 106, -90, -59, -30, -27, -110, -110), + chainCode = byteArrayOf(-95, -87, -95, -25, 27, 96, -57, -92, -69, -106, -45, 10, 85, 4, -92, -68, 49, -24, -28, -50, -49, -77, -20, 118, -50, -27, 104, -93, 115, -50, -46, -34), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AC05000000086747") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt index 44fdad55f0..eb76dc0b7a 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsScreen.kt @@ -13,11 +13,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.DeviceSettingsScreenTestTags import com.tangem.tap.features.details.ui.common.DetailsMainButton import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold import com.tangem.wallet.R @@ -49,7 +51,8 @@ private fun CardSettingsReadCard(onScanCardClick: () -> Unit) { Box( modifier = Modifier .fillMaxWidth() - .padding(bottom = TangemTheme.dimens.spacing40), + .padding(bottom = TangemTheme.dimens.spacing40) + .testTag(DeviceSettingsScreenTestTags.IMAGE_BLOCK), ) { Image( modifier = Modifier @@ -116,7 +119,9 @@ private fun CardSettings(state: CardSettingsScreenState) { if (state.cardDetails == null) return LazyColumn( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(DeviceSettingsScreenTestTags.LAZY_LIST), ) { items(state.cardDetails) { val paddingBottom = when (it) { @@ -164,12 +169,14 @@ private fun CardSettings(state: CardSettingsScreenState) { text = it.titleRes.resolveReference(), color = titleColor, style = TangemTheme.typography.subtitle1, + modifier = Modifier.testTag(DeviceSettingsScreenTestTags.ITEM_TITLE), ) Spacer(modifier = Modifier.size(TangemTheme.dimens.size4)) Text( text = it.subtitle.resolveReference(), color = subtitleColor, style = TangemTheme.typography.body2, + modifier = Modifier.testTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE), ) } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt index dd3093deb9..e81602a4d2 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt @@ -9,12 +9,14 @@ import androidx.compose.material3.IconToggleButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.* import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.ResetCardScreenTestTags import com.tangem.tap.features.details.ui.cardsettings.TextReference import com.tangem.tap.features.details.ui.cardsettings.resolveReference import com.tangem.tap.features.details.ui.common.DetailsMainButton @@ -74,6 +76,7 @@ private fun Title() { text = stringResourceSafe(id = R.string.card_settings_reset_card_to_factory), style = TangemTheme.typography.h1, color = TangemTheme.colors.text.primary1, + modifier = Modifier.testTag(ResetCardScreenTestTags.TITLE), ) } @@ -82,7 +85,9 @@ private fun AlertImage() { Image( painter = painterResource(id = R.drawable.img_alert_80), contentDescription = null, - modifier = Modifier.size(TangemTheme.dimens.size80), + modifier = Modifier + .size(TangemTheme.dimens.size80) + .testTag(ResetCardScreenTestTags.ATTENTION_IMAGE), ) } @@ -92,6 +97,7 @@ private fun Subtitle() { text = stringResourceSafe(id = R.string.common_attention), style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, + modifier = Modifier.testTag(ResetCardScreenTestTags.SUBTITLE), ) } @@ -101,6 +107,7 @@ private fun Description(text: TextReference) { text = text.resolveReference(), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, + modifier = Modifier.testTag(ResetCardScreenTestTags.DESCRIPTION), ) } @@ -135,7 +142,11 @@ private fun ConditionCheckBox(checkedState: Boolean, onCheckedChange: (Boolean) .padding(vertical = TangemTheme.dimens.size16), horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing16), ) { - IconToggleButton(checked = checkedState, onCheckedChange = onCheckedChange) { + IconToggleButton( + checked = checkedState, + onCheckedChange = onCheckedChange, + modifier = Modifier.testTag(ResetCardScreenTestTags.CHECKBOX), + ) { AnimatedContent(targetState = checkedState, label = "Update checked state") { checked -> Icon( painter = painterResource( @@ -159,6 +170,7 @@ private fun ConditionCheckBox(checkedState: Boolean, onCheckedChange: (Boolean) text = description.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, + modifier = Modifier.testTag(ResetCardScreenTestTags.CHECKBOX_TEXT), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/DeviceSettingsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/DeviceSettingsScreenTestTags.kt new file mode 100644 index 0000000000..a995632d4f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/DeviceSettingsScreenTestTags.kt @@ -0,0 +1,8 @@ +package com.tangem.core.ui.test + +object DeviceSettingsScreenTestTags { + const val LAZY_LIST = "DEVICE_SETTINGS_SCREEN_LAZY_LIST" + const val IMAGE_BLOCK = "DEVICE_SETTINGS_SCREEN_IMAGE_BLOCK" + const val ITEM_TITLE = "DEVICE_SETTINGS_SCREEN_ITEM_TITLE" + const val ITEM_SUBTITLE = "DEVICE_SETTINGS_SCREEN_ITEM_SUBTITLE" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/ResetCardScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/ResetCardScreenTestTags.kt new file mode 100644 index 0000000000..0f0409aba4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/ResetCardScreenTestTags.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.test + +object ResetCardScreenTestTags { + const val TITLE = "RESET_CARD_SCREEN_TITLE" + const val ATTENTION_IMAGE = "RESET_CARD_SCREEN_ATTENTION_IMAGE" + const val SUBTITLE = "RESET_CARD_SCREEN_SUBTITLE" + const val DESCRIPTION = "RESET_CARD_SCREEN_DESCRIPTION" + const val CHECKBOX = "RESET_CARD_SCREEN_CHECKBOX" + const val CHECKBOX_TEXT = "RESET_CARD_SCREEN_CHECKBOX_TEXT" +} \ No newline at end of file From aa5774c3a136e6a994c97371941250e17ad6d692 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 13:45:47 +0000 Subject: [PATCH 071/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 2572f02b34..4829355867 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1218" +tangemBlockchainSdk = "develop-1216" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-527" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From ac121ce00840f73b8d13a0b088efbc1e1a03439d Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 15:37:16 +0300 Subject: [PATCH 072/132] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 9 ++ .../modal/TangemModalBottomSheet.kt | 14 ++- .../solana/WcSolanaSignTransactionUseCase.kt | 81 ++++++++++++-- .../SendLargeSolanaTransactionUseCase.kt | 55 +++++++++ .../usecase/method/WcTransactionUseCase.kt | 7 ++ .../WcCommonTransactionComponentDelegate.kt | 2 + .../components/common/WcNavigationUtils.kt | 24 +++- .../WcSendMultipleTransactionsComponent.kt | 33 ++++++ .../send/WcSendingProcessComponent.kt | 30 +++++ .../model/WcSendTransactionModel.kt | 27 ++++- .../transaction/routes/WcTransactionRoutes.kt | 9 ++ ...endMultipleTransactionsModalBottomSheet.kt | 104 ++++++++++++++++++ .../send/WcSendingProcessModalBottomSheet.kt | 77 +++++++++++++ gradle/tangem_dependencies.toml | 2 +- 14 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt create mode 100644 features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt create mode 100644 features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendingProcessComponent.kt create mode 100644 features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt create mode 100644 features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendingProcessModalBottomSheet.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 96c968aa66..d34fc1d288 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -235,4 +235,13 @@ internal object TransactionDomainModule { ): GetReverseResolvedEnsAddressUseCase { return GetReverseResolvedEnsAddressUseCase(walletAddressServiceRepository) } + + @Provides + @Singleton + fun sendLargeSolanaTransactionUseCase( + cardSdkConfigRepository: CardSdkConfigRepository, + walletManagersFacade: WalletManagersFacade, + ): SendLargeSolanaTransactionUseCase { + return SendLargeSolanaTransactionUseCase(cardSdkConfigRepository, walletManagersFacade) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index dc68d70134..0c76ab802c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -49,6 +49,7 @@ inline fun TangemModalBottomSheet( config: TangemBottomSheetConfig, containerColor: Color = TangemTheme.colors.background.primary, skipPartiallyExpanded: Boolean = true, + dismissOnClickOutside: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable ColumnScope.(T) -> Unit, @@ -69,6 +70,7 @@ inline fun TangemModalBottomSheet( containerColor = containerColor, title = title, content = content, + dismissOnClickOutside = dismissOnClickOutside, onBack = onBack, skipPartiallyExpanded = skipPartiallyExpanded, ) @@ -81,12 +83,22 @@ inline fun DefaultModalBottomSheet( config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, + dismissOnClickOutside: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { var isVisible by remember { mutableStateOf(value = config.isShown) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = skipPartiallyExpanded, + confirmValueChange = { + if (!dismissOnClickOutside) { + it != SheetValue.Hidden // Ignore transitions to hidden (prevents dismiss on outside click/back press) + } else { + true + } + }, + ) if (isVisible && config.content is T) { BasicModalBottomSheet( diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt index c1d301135a..2a3d2c624f 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignTransactionUseCase.kt @@ -1,6 +1,7 @@ package com.tangem.data.walletconnect.network.solana import arrow.core.left +import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.extensions.encodeBase58 import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -10,10 +11,13 @@ import com.tangem.data.walletconnect.sign.SignCollector import com.tangem.data.walletconnect.sign.SignStateConverter.toResult import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.usecase.PrepareAndSignUseCase +import com.tangem.domain.transaction.usecase.SendLargeSolanaTransactionUseCase import com.tangem.domain.walletconnect.error.parseSendError import com.tangem.domain.walletconnect.model.WcSolanaMethod import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck +import com.tangem.domain.walletconnect.usecase.method.SignRequirements import com.tangem.domain.walletconnect.usecase.method.WcSignState import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase import dagger.assisted.Assisted @@ -22,18 +26,21 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import okio.ByteString.Companion.decodeBase64 +import timber.log.Timber @Suppress("LongParameterList") internal class WcSolanaSignTransactionUseCase @AssistedInject constructor( override val respondService: WcRespondService, override val analytics: AnalyticsEventHandler, private val prepareAndSign: PrepareAndSignUseCase, + private val sendLargeSolanaTransactionUseCase: SendLargeSolanaTransactionUseCase, @Assisted override val context: WcMethodUseCaseContext, @Assisted override val method: WcSolanaMethod.SignTransaction, blockAidDelegate: BlockAidVerificationDelegate, addressConverter: SolanaBlockAidAddressConverter, ) : BaseWcSignUseCase(), - WcTransactionUseCase { + WcTransactionUseCase, + SignRequirements { override val securityStatus = blockAidDelegate.getSecurityStatus( network = network, @@ -44,15 +51,35 @@ internal class WcSolanaSignTransactionUseCase @AssistedInject constructor( ).map { lce -> lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } } override suspend fun SignCollector.onSign(state: WcSignState) { - val hash = prepareAndSign.invoke(transactionData = state.signModel, userWallet = wallet, network = network) - .onLeft { error -> - emit(state.toResult(parseSendError(error).left())) - } - .getOrNull() - ?: return - val respond = "{ signature: \"${hash.encodeBase58()}\" }" - val respondResult = respondService.respond(rawSdkRequest, respond) - emit(state.toResult(respondResult)) + val hash = state.signModel.getTxHashFromCompiled() + val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction + if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) { + // workaround for large transactions that cannot be signed directly by card + Timber.w("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes") + sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash) + .fold( + ifLeft = { + Timber.e(it.toString()) + emit(state.toResult(parseSendError(it).left())) + }, + ifRight = { + val emptyRespond = ByteArray(0).formatAsSolanaSignature() + val respondResult = respondService.respond(rawSdkRequest, emptyRespond) + emit(state.toResult(respondResult)) + }, + ) + } else { + val signedHash = + prepareAndSign.invoke(transactionData = state.signModel, userWallet = wallet, network = network) + .onLeft { error -> + emit(state.toResult(parseSendError(error).left())) + } + .getOrNull() + ?: return + val respond = signedHash.formatAsSolanaSignature() + val respondResult = respondService.respond(rawSdkRequest, respond) + emit(state.toResult(respondResult)) + } } override fun invoke(): Flow> { @@ -64,6 +91,40 @@ internal class WcSolanaSignTransactionUseCase @AssistedInject constructor( return delegate.invoke(transactionData) } + private fun ByteArray.formatAsSolanaSignature(): String { + return "{ signature: \"${this.encodeBase58()}\" }" + } + + private fun TransactionData.getTxHashFromCompiled(): ByteArray { + return when (this) { + is TransactionData.Compiled -> (value as? TransactionData.Compiled.Data.Bytes)?.data + ?: error("Invalid transaction data") + is TransactionData.Uncompiled -> error("Transaction must be compiled") + } + } + + private fun isLargeHash(hash: ByteArray): Boolean { + return hash.size > LARGE_HASH_SIZE + } + + private fun getFormattedHash(hash: ByteArray): ByteArray { + return try { + SolanaTransactionHelper.removeSignaturesPlaceholders(hash) + } catch (e: Exception) { + Timber.e("Failed to format the hash: ${e.message}") + hash + } + } + + override fun isMultipleSignRequired(): Boolean { + val data = method.transaction.decodeBase64()?.toByteArray() ?: ByteArray(0) + return isLargeHash(data) + } + + private companion object { + private const val LARGE_HASH_SIZE = 930 // bytes + } + @AssistedFactory interface Factory { fun create( diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt new file mode 100644 index 0000000000..819faacd7b --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt @@ -0,0 +1,55 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.blockchains.solana.SolanaWalletManager +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.walletmanager.WalletManagersFacade + +/** + * Use case for sending large Solana transactions that cannot be signed directly by the card. + * It handles the transaction creating alt tables and sending the transaction through the Solana network. + * + * @property cardSdkConfigRepository Repository to access card SDK configurations and signers. + * @property walletManagersFacade Facade to manage and retrieve wallet managers. + */ +class SendLargeSolanaTransactionUseCase( + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend operator fun invoke( + userWallet: UserWallet.Cold, + network: Network, + txHash: ByteArray, + ): Either { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + + val signer = cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + ) + + val walletManager = walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, network) + ?: error("WalletManager is null") + + if (walletManager !is SolanaWalletManager) return SendTransactionError.UnknownError().left() + val result = walletManager.handleLargeLegacyTransaction(signer, txHash) + return when (result) { + is Result.Failure -> SendTransactionError.BlockchainSdkError( + code = result.error.code, + message = result.error.customMessage, + ).left() + is Result.Success -> Unit.right() + } + } +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt index af7e9c585f..150fd03424 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcTransactionUseCase.kt @@ -46,4 +46,11 @@ interface WcMutableFee { interface WcApproval { fun getAmount(): WcApprovedAmount? fun updateAmount(amount: WcApprovedAmount?) +} + +/** + * Defines if multiple signatures are required for the transaction + */ +interface SignRequirements { + fun isMultipleSignRequired(): Boolean } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt index 1ddad75e91..36c5a2b3c1 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcCommonTransactionComponentDelegate.kt @@ -62,6 +62,8 @@ internal abstract class WcCommonTransactionComponentDelegate( is WcTransactionRoutes.Alert, is WcTransactionRoutes.CustomAllowance, is WcTransactionRoutes.SelectFee, + is WcTransactionRoutes.MultipleTransactions, + WcTransactionRoutes.TransactionProcess, -> stackNavigation?.pop() null -> Unit } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt index 292804e584..5be85fef22 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt @@ -4,10 +4,13 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.features.send.v2.api.FeeSelectorComponent -import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.api.params.FeeSelectorParams.FeeDisplaySource +import com.tangem.features.send.v2.api.params.FeeSelectorParams.FeeSelectorDetailsParams import com.tangem.features.walletconnect.connections.components.AlertsComponentV2 import com.tangem.features.walletconnect.connections.utils.WcAlertsFactory.createCommonTransactionAppInfoAlertUM import com.tangem.features.walletconnect.transaction.components.send.WcCustomAllowanceComponent +import com.tangem.features.walletconnect.transaction.components.send.WcSendingProcessComponent +import com.tangem.features.walletconnect.transaction.components.send.WcSendMultipleTransactionsComponent import com.tangem.features.walletconnect.transaction.entity.common.WcCommonTransactionModel import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes @@ -43,17 +46,32 @@ internal fun getWcCommonScreen( feeSelectorComponentFactory.create( context = appComponentContext, onDismiss = model::popBack, - params = FeeSelectorParams.FeeSelectorDetailsParams( + params = FeeSelectorDetailsParams( state = state.feeSelectorUM, onLoadFee = model::loadFee, feeCryptoCurrencyStatus = model.cryptoCurrencyStatus, cryptoCurrencyStatus = model.cryptoCurrencyStatus, callback = model, feeStateConfiguration = model.feeStateConfiguration, - feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, + feeDisplaySource = FeeDisplaySource.BottomSheet, analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, ), ) } + is WcTransactionRoutes.MultipleTransactions -> { + val model = model as? WcSendTransactionModel ?: error("model must be WcSendTransactionModel") + WcSendMultipleTransactionsComponent( + appComponentContext = appComponentContext, + model = model, + onConfirm = config.onConfirm, + ) + } + WcTransactionRoutes.TransactionProcess -> { + val model = model as? WcSendTransactionModel ?: error("model must be WcSendTransactionModel") + WcSendingProcessComponent( + appComponentContext = appComponentContext, + model = model, + ) + } } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt new file mode 100644 index 0000000000..0146849910 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt @@ -0,0 +1,33 @@ +package com.tangem.features.walletconnect.transaction.components.send + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel +import com.tangem.features.walletconnect.transaction.ui.send.WcSendMultipleTransactionsModalBottomSheet + +internal class WcSendMultipleTransactionsComponent( + private val appComponentContext: AppComponentContext, + private val model: WcSendTransactionModel, + private val onConfirm: () -> Unit, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + WcSendMultipleTransactionsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { model.popBack() }, + content = TangemBottomSheetConfigContent.Empty, + ), + onConfirm = onConfirm, + onBack = { model.popBack() }, + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendingProcessComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendingProcessComponent.kt new file mode 100644 index 0000000000..5723380bbb --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendingProcessComponent.kt @@ -0,0 +1,30 @@ +package com.tangem.features.walletconnect.transaction.components.send + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel +import com.tangem.features.walletconnect.transaction.ui.send.WcSendingProcessModalBottomSheet + +internal class WcSendingProcessComponent( + private val appComponentContext: AppComponentContext, + private val model: WcSendTransactionModel, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + WcSendingProcessModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index 8b5afff900..99b80940ee 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -141,7 +141,13 @@ internal class WcSendTransactionModel @Inject constructor( val isApprovalMethod = isSecurityCheckContent && securityCheck.content is BlockAidTransactionCheck.Result.Approval wcApproval = useCase as? WcApproval - sign = { useCase.sign() } + sign = { + if (isMultipleSignRequired(useCase)) { + openMultipleTransaction(useCase) + } else { + useCase.sign() + } + } buildUiState(securityCheck, useCase, signState, isApprovalMethod) if (feeReloadState.value) { triggerFeeReload() @@ -162,6 +168,17 @@ internal class WcSendTransactionModel @Inject constructor( } } + private fun openMultipleTransaction(useCase: WcSignUseCase<*>) { + stackNavigation.pushNew( + WcTransactionRoutes.MultipleTransactions( + onConfirm = { + useCase.sign() + stackNavigation.pushNew(WcTransactionRoutes.TransactionProcess) + }, + ), + ) + } + /** * Handles callback with updated [feeSelectorUM] from click FeeSelectorComponent. * We need to trigger navigation to dismiss fee selector component @@ -272,6 +289,14 @@ internal class WcSendTransactionModel @Inject constructor( stackNavigation.pop() } + private fun isMultipleSignRequired(useCase: WcSignUseCase<*>): Boolean { + return if (useCase is SignRequirements) { + useCase.isMultipleSignRequired() + } else { + false + } + } + fun showTransactionRequest() { analytics.send( WcAnalyticEvents.TransactionDetailsOpened( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt index 843e0e291d..cc1d9513d7 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/routes/WcTransactionRoutes.kt @@ -34,6 +34,7 @@ internal sealed class WcTransactionRoutes : TangemBottomSheetConfigContent, Rout val iconType: MessageBottomSheetUMV2.Icon.Type, val iconBgType: MessageBottomSheetUMV2.Icon.BackgroundType, ) : Type() + data class UnknownError( val errorMessage: String?, val onDismiss: () -> Unit, @@ -41,4 +42,12 @@ internal sealed class WcTransactionRoutes : TangemBottomSheetConfigContent, Rout ) : Type() } } + + @Serializable + data class MultipleTransactions( + val onConfirm: () -> Unit, + ) : WcTransactionRoutes() + + @Serializable + data object TransactionProcess : WcTransactionRoutes() } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt new file mode 100644 index 0000000000..e8687a8fb3 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt @@ -0,0 +1,104 @@ +package com.tangem.features.walletconnect.transaction.ui.send + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun WcSendMultipleTransactionsModalBottomSheet( + config: TangemBottomSheetConfig, + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + TangemModalBottomSheet( + config = config, + content = { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerH24() + Icon( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(percent = 100)) + .background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f)) + .padding(12.dp), + painter = rememberVectorPainter( + ImageVector.vectorResource(com.tangem.core.ui.R.drawable.ic_alert_24), + ), + tint = TangemTheme.colors.icon.attention, + contentDescription = null, + ) + SpacerH24() + Text( + text = "Multiple Transactions", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = "You’ll need to tap your Tangem device a few times to complete this process.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(48.dp) + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = "Return", + onClick = onBack, + ) + SpacerH8() + PrimaryButtonIconEnd( + modifier = Modifier.fillMaxWidth(), + iconResId = R.drawable.ic_tangem_24, + text = "Send", + onClick = onConfirm, + ) + } + }, + ) +} + +@Composable +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun WcSendTransactionBottomSheetPreview() { + TangemThemePreview { + WcSendMultipleTransactionsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + onConfirm = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendingProcessModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendingProcessModalBottomSheet.kt new file mode 100644 index 0000000000..17087e2059 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendingProcessModalBottomSheet.kt @@ -0,0 +1,77 @@ +package com.tangem.features.walletconnect.transaction.ui.send + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun WcSendingProcessModalBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + onBack = { + // empty to disable back handling + }, + dismissOnClickOutside = false, + content = { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerH(64.dp) + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size32), + color = TangemTheme.colors.text.accent, + strokeWidth = TangemTheme.dimens.size2, + ) + SpacerH(32.dp) + Text( + text = "Sending your funds", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = "We’re processing the transaction — it’ll be complete in moments.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(56.dp) + } + }, + ) +} + +@Composable +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun WcSendTransactionBottomSheetPreview() { + TangemThemePreview { + WcSendingProcessModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + ) + } +} \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 4829355867..25b1732ddf 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1216" +tangemBlockchainSdk = "develop-1222" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 941baf081b423bc9ab4459be9dd7009668ce4762 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 18:02:30 +0400 Subject: [PATCH 073/132] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 8 ++ .../res/drawable/ic_credit_card_add_22.xml | 16 ++++ .../res/drawable/ic_security_check_22.xml | 9 ++ .../res/drawable/ic_shopping_basket_22.xml | 10 +++ .../onboarding/impl/build.gradle.kts | 2 + .../model/TangemPayOnboardingModel.kt | 8 +- .../tangempay/ui/TandemPayOnboardingScreen.kt | 23 +++-- .../tangempay/ui/TangemPayOnboardingBlock.kt | 48 +++++++++++ .../tangempay/ui/TangemPayOnboardingBlocks.kt | 36 ++++++++ .../ui/TangemPayOnboardingContent.kt | 80 ++++++++++++++++++ .../ui/TangemPayOnboardingScreenState.kt | 8 +- .../img_tangem_pay_onboarding.webp | Bin 0 -> 490698 bytes .../drawable/img_tangem_pay_onboarding.webp | Bin 0 -> 196856 bytes 13 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_credit_card_add_22.xml create mode 100644 core/ui/src/main/res/drawable/ic_security_check_22.xml create mode 100644 core/ui/src/main/res/drawable/ic_shopping_basket_22.xml create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt create mode 100644 features/tangempay/onboarding/impl/src/main/res/drawable-night/img_tangem_pay_onboarding.webp create mode 100644 features/tangempay/onboarding/impl/src/main/res/drawable/img_tangem_pay_onboarding.webp diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index d35ab1c6cf..4db0f5063e 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1414,6 +1414,14 @@ Unlock Scan your card to unlock access Needed unlock + Get your free Crypto Card\nin minutes + Built-in security + Your card details are protected — full control in the app. + Everyday purchases + Use your USDC balance to pay for everyday purchases seamlessly. + Apple Pay & Google Pay + Add to your wallet and pay with your phone anywhere. + Get card Blockchain is unreachable. Try later Scan card or ring This wallet has already been activated earlier.\nIf it was not done by you, please contact support.\nTangem never sells wallets along with pre-generated access codes. diff --git a/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml b/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml new file mode 100644 index 0000000000..560684e1a9 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_security_check_22.xml b/core/ui/src/main/res/drawable/ic_security_check_22.xml new file mode 100644 index 0000000000..fd8956d8fa --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_security_check_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml b/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml new file mode 100644 index 0000000000..c6cb19e2e7 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/build.gradle.kts b/features/tangempay/onboarding/impl/build.gradle.kts index ab2de5b977..8ebb588cdd 100644 --- a/features/tangempay/onboarding/impl/build.gradle.kts +++ b/features/tangempay/onboarding/impl/build.gradle.kts @@ -18,9 +18,11 @@ dependencies { /** Common */ implementation(projects.common.routing) + implementation(projects.common.ui) /** Features api */ implementation(projects.features.tangempay.onboarding.api) + implementation(projects.features.kyc.api) /** Compose */ implementation(deps.compose.foundation) diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 02ca64323d..482864d146 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -8,7 +8,7 @@ import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @Stable @@ -21,8 +21,6 @@ internal class TangemPayOnboardingModel @Inject constructor( @Suppress("UnusedPrivateMember") private val params = paramsContainer.require() - private val _screenState: MutableStateFlow = MutableStateFlow(getInitState()) - val screenState = _screenState.asStateFlow() - - private fun getInitState() = TangemPayOnboardingScreenState + val screenState: StateFlow + field = MutableStateFlow(TangemPayOnboardingScreenState()) } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt index 008f2c8798..42882bafb0 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt @@ -1,15 +1,17 @@ package com.tangem.features.tangempay.ui +import android.content.res.Configuration import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.res.TangemThemePreview -@Suppress("UnusedPrivateMember") @Composable internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { Scaffold( @@ -18,14 +20,25 @@ internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, mo AppBarWithBackButton( modifier = Modifier.statusBarsPadding(), onBackClick = {}, - text = "", iconRes = R.drawable.ic_back_24, ) }, content = { paddingValues -> - val contentModifier = Modifier - .padding(paddingValues) - .fillMaxSize() + TangemPayOnboardingContent( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = state, + ) }, ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewDarkTheme() { + TangemThemePreview { + TandemPayOnboardingScreen(state = TangemPayOnboardingScreenState()) + } } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt new file mode 100644 index 0000000000..94dc71b1ba --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt @@ -0,0 +1,48 @@ +package com.tangem.features.tangempay.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun TangemPayOnboardingBlock( + @DrawableRes painterRes: Int, + titleRef: TextReference, + descriptionRef: TextReference, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding(start = 32.dp, end = 32.dp), + ) { + Icon( + painter = painterResource(id = painterRes), + contentDescription = null, + modifier = Modifier + .width(24.dp) + .height(24.dp), + ) + Column( + modifier = Modifier.padding(start = 12.dp), + ) { + Text( + text = titleRef.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = descriptionRef.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt new file mode 100644 index 0000000000..71ae0aadcc --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt @@ -0,0 +1,36 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.extensions.TextReference + +@Composable +internal fun TangemPayOnboardingBLocks(modifier: Modifier = Modifier) { + TangemPayOnboardingBlock( + modifier = modifier, + painterRes = R.drawable.ic_security_check_22, + titleRef = TextReference.Res(R.string.tangempay_onboarding_security_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_security_description), + ) + + SpacerH(18.dp) + + TangemPayOnboardingBlock( + modifier = modifier, + painterRes = R.drawable.ic_shopping_basket_22, + titleRef = TextReference.Res(R.string.tangempay_onboarding_purchases_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_purchases_description), + ) + + SpacerH(18.dp) + + TangemPayOnboardingBlock( + modifier = modifier, + painterRes = R.drawable.ic_credit_card_add_22, + titleRef = TextReference.Res(R.string.tangempay_onboarding_pay_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_pay_description), + ) +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt new file mode 100644 index 0000000000..ee247407a6 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt @@ -0,0 +1,80 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.onboarding.impl.R + +@Composable +internal fun TangemPayOnboardingContent(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { + if (state.fullScreenLoading) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier, + color = TangemTheme.colors.icon.primary1, + ) + } + } else { + Column( + modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(top = 24.dp, bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.img_tangem_pay_onboarding), + contentDescription = null, + modifier = Modifier + .height(250.dp) + .fillMaxWidth(), + ) + + Text( + text = stringResourceSafe(R.string.tangempay_onboarding_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + SpacerH(32.dp) + + TangemPayOnboardingBLocks() + } + + NavigationPrimaryButton( + modifier = Modifier + .imePadding() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + primaryButton = NavigationButton( + textReference = resourceReference(R.string.tangempay_onboarding_get_card_button_text), + iconRes = R.drawable.ic_tangem_24, + isIconVisible = true, + showProgress = state.buttonLoading, + onClick = {}, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt index a0681a0db9..8745cf3db9 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt @@ -1,3 +1,9 @@ package com.tangem.features.tangempay.ui -internal object TangemPayOnboardingScreenState \ No newline at end of file +import javax.annotation.concurrent.Immutable + +@Immutable +internal data class TangemPayOnboardingScreenState( + val fullScreenLoading: Boolean = true, + val buttonLoading: Boolean = false, +) \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/res/drawable-night/img_tangem_pay_onboarding.webp b/features/tangempay/onboarding/impl/src/main/res/drawable-night/img_tangem_pay_onboarding.webp new file mode 100644 index 0000000000000000000000000000000000000000..97f81422fd59d7461a8c6f381ec87dae232242dc GIT binary patch literal 490698 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�{z7flmTFl^O_^2uIgvangkQsSG=s^raHfI2! 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
p=>fx!!pJJw7JAg=sQH3??#U!p||B z_u!YiMF-4y8#6FF?Zex{ClGT*#qI$hjxik{+fc^gbod~un@A|Jk&-bM;XFJ9|(IxYk}c*;5W z*fCtgd4wL4212aBfb*g0E$L|kHOPlJkwFip#lca|5o20^C)hU!q|}L*6jQ331yC&@ zIo~j9=b@#T;!o`^b{Tr8x7=Qh;qVq);u|i&SsZp~nfeqYGto=EeB9trfe3D{zsq?7 z=X`Z-?Y5*-3J+~iBWcaO6bOL{9@)z&^0!bxjzm!u#T%!TDn%ufy=MB-XEwg)_%(9Z zuc*9mK0vGQ#J=a_A0o<~bgT_03ipa~4T49e(^@PU#lUZ?CW*ZU!I&!FwrSikm0P#q zI@>7PP)b-I4GQu-GK}s~YT>eEjyMnk#`ykaM z_fdk7vaFXayE5wG?=B^QWTZ^#LReJI8$+?ciI)^JK+6?I)VaFkvPEAgaUlX=aJxQY zqf$wx2bV)iWn`qmUiR>g2u1*+^2ZC@ttxz8i-Lc$TSy1(-WT}Hpn0`ec30jG$NN@7 zXUoZM6O~6$Ra5%nKcTV>Jf3@__JJi=K!v>EOb!*pVDZ#82$fFf{v~k2uyJ&5YTKGC zKHG`Lr;o()^$%Om4T`c=H9JqhgaJaey)#_`*9mW>-rf6)o+Xi`1zev0a<;7;he!Qb z3$z``QIoyhCi?N)` z6-i^eC^na6lx{xQAQG)(pt92*J*wknw5Y-uDMJ*PIJay72+#T;{qP<~4u^`vxRl%}mxdJ>B0|9X zPypb=j*qO$lK^kynubHTG1W0aS=0qgF|9$gphIjVxALknDd#}d6%u*17iZmY|J`Cma4}Ow!Z}YkpVBbzPUTYpkcqcwqu$() z(r}8>ccWTu>d<*>`8YKVguh~%z|_OMqH1PJ%5;A%FL>Wtk=R;68`*_+*tRBHIWrzy z3~89cdbG><6v7ol2dEN)mHxXY7yz*S9iXXLK;(x&;JJu%kzaOz-pC7zy>sW*u`IW@ zr>a4eGvsqAC+fh+anp4T0H*EAF$u%ybG1yz<`BZSC?$;vmDp@^E?NR;GDk!Z+Z}&o zyR{(@(nH|iCR!X(PTUJ2Du9QUmYRcr1um8=08t@?>q@Q`D>d)CSS@8;R{%o#0Ia$L zWFTc<-zy6 zT7`p^5E1L`8sNxZ=XI`&;?&fPz4TF;0aGt=(7Ht2GSk z>5Ff3zDmq3Azwp0QHOY!z4YzpFzdH`ibaI%A@mt<`2-?ajfu>6tzgy65E%0w1lZ2p zQ@yfmvdKJ-V^*I?<8q`=?F6yMEahGktjqE&F0kMMQ=W1yMnwU}2r>}iAiHQ#MVsg@-PrR6-#5E}=`Hs(8W6&gFu!2?8_-Mg4q$5^{C=Em7a+$d9o7BKr6 zZEpuqxIXbPUojYl_4qYQqydLj27?+Ag5rwWUR*?2`Y`Y)=l569{MjTZsovweWRkJ-Ii@W{6HdBUU4>c zSR>`)NNK7xhORXeNgR&m)STiR#1k5^s)ka}RK)ql+w@&Sws~lf5l4Uw`9oWUQc9VN zVz;8Mx`+0KLKI(!t=x@DNOc1Ky5GY)iXN6}+8Fghafa`gqBk!4htp_!8Pk@MTrL-5 zhd6N@hL<{bm!E77<%QN@&tSRZKG@r7d*SG0JT&^CB9RL)98o3R?;=E`vD`Vd=J2Lu z4Uy9h2YWPTkFkplb#prdpb>gR+ec(8gfw`ubP#pYhyC?pyS|+8S|Kwa8>^Mh;oWRb zq8=c|X+^o5#UmsalA-XWg^YVs)GEFE-A3!kDtDwi7f}^K3gJq)0Yt}oltCqfjkq@m z>E;ByiD}nEt|=iXflC-{k<$_6BlE|)^Mybks(pHas5fTxG1lhcVIb~jrO|Dzk$TCs z+?GMKOT8LRzmJ6@SzOtXU6jB)bP(2yVt(8Yti=}B_|sDr6b2u@iR7C!-;UGZL4|) z577fx>Zx@*#^F}%{$OU z1m`-C9=YbiEbx!P`<1NofI(;2begM+X{B(AmHi)@OAg`efl67*B^Mo83eB;;$T>GJ zkwLK;Ncf5wf*D`qSkFU4GC;(Z7kUdpg?)6C!E`!m!lVfobxi^t(Sa6)Cjjp(Q`3cC z47fgkaTRU>HUWiuQ&EX1ngM|TKCG)G5mwa;Lj_Lch$4F57 zE`(Vu;6|OP_<9f`xU-nYz| zgrp(lat_sMU}lvn2OPq6A>7=A9kLwjWfO=1w`c&q%RE^_bBzfUI!ECRjmBm7GB+a> zjHkeNrFJMZm8+e64D2M`lF_$$Hk-N+B&H*0}^P>s6VA?j#Nw@})ruPQx42n3L! zA35bRCS)O3LKJ$IQ_>0dyr#_gsqNLQhM<(p2%Me4V)BIVz%ZIQCvzg*jHaByghwSG zh(YrB@nvN=$pEZ9J&l#RsfaR{7qJJSz_HbZGFu%_&>}zv!ie|YHGElM3E(Rgn%J98 zE2r9dk?&b673qbI?cfPP{71(8;dD3xl`v#fXYZL7x^3?$*y!BkJh(S zhl^f`EA)$Ovtq#C2^FTMgPgVP(Mm2YXL9J|Wi(?pd(X=RcNSUf_O0<=~XP_2qVCm;drR5_QTqw;(; z0+>=K)SYRw+O0#sx;ZoEJHk|z8Xg7WSk5>PTcW~9th^#)x*V7{>wJ{?%+*%Cx&m<% zMLdVLmoE~9V<9M}Bn}SH>zBZv+CpvAv1M8SR(da(3(D(Vl$B8q>uj2b;g&v)bQBDKEXn{Izl(70sL&6=m=F+1 zI*uFpz0VMl3oJw>YjM%(_$nuT>E@P2r&H4F;)OoVvpjbt8TqhY%$#u%<4!Y%3NgwerKycZohtWNlEvb-dj{`RGiw?IX zr@NvUk8N#!R0BtFbxFy>2}BP2Jk`o^`XNuLxVYHjKnn+flE*U#`s2R~4!Fc(DN+Ka zx7Ha?%VLm~Y;RO>qN`}<{fk>h6x8|zI|7XDGTvpXRq6^)o7U_+wYd11e5~74FMpow54_v|@cw=poJvT{8)2GD zh86RikKaa6XXe>n0pA01mynCV>CN8J#gRHp98|N`?NfJ(R8I8>#StpTm>$kKpgKe# z7SBXLEF~C-RsmDd{I(5%bw;NW4xr#X$<2O%=BWf7EZEVmnTBD*9xp^?iJO;D8jRrZ z6$6AeN-1uyA#0(!3~*p5gP>;GFdT7U^rT`ZClaIlUF93zFPzCL8zQX^(e$5fZKg28 zDo^;XpI%rcV%(Cd>L@^|1(l6J4^1rdVrxs|xGto;>ug#WZ$V9?&}*`fS9~&UP5;n^ zSlJUPm)6(qe+qY?=sZ*usxZnIKv5Kf4T@o}ZX2(DalCh{hjdlw03+;&nIh$9YTJo4 z&$>K;)N{PQmrXpyPJtYswKX%?Rlh5C(o}G0Y@OrBkP54pCp} z8aAd6SC+cF`AYVXrfySXt5)#~9XMNQ7C!!ITOL>daX^m0))By!qHI{M*S^Cp&@pt- z&~*0c)sUFF+Ns+TWc9`;Z54R>ua>zW>-!n_@j=}`SJ^9i9uM3R@=$pjHd`ABbtao~ z4+vis7JSOoMIORh=$KcnRxNHH)UN(LRMv8xl2bv@RolD6emM_XRU=znXOeM%Br$br z)0rv^?b^^z>g6^{HuVr#^kuP!sLyqQ%Mjd2@MZ~i8DN%5p%PoglFIJRCacrf?Js`P z?G+Ae;ElK4tz?}*je3r!p0biYAvhpuY^>Y);>o^zb76b@%*Exh4ji)>_TSft`c%9> z!U3sj?MP3lRLBfFm!6S;*)`4Z=;+$Dbq$9CWVFCX&AQp&<2n5Y8wtgK2xxt^v*b3W zKe_BN|i<`tDoTd?x&2c!+{dM$s!|G0yk-WF>^W zk}rciy<_sk6T<|wiMw01j4Ah~M{;6hhh#=`bK(f7=+v!NZ34XN*wzV8=)rc^!Nf~N zZ$}`q?f5CH<4S3cC>^LoVCWuxfe=T7hdNYApR0;aYz-|N+dbt9VJerU%BB zl0CPZC)4I7nB&HPh;QxJo!In=;n7xU$YOa>&&^ssW@}+_yfhaqx^8p&pcuL3isX1x zO$hx8R3G}bAx!#or*keAZC=A#u#&qyKzky$S4n%7S0iWoa))lJn`*QJ_L6J5S*sM* znPDy#d^A+}aq2`%qvbZRmW_nMS-Si$#&wAvi$g}_%w2N;k<83cx7f#ets7-QZY?CTe|vgsxz1d{bfughp2wA#RA33ZPF--B z35^pJ=VA)eytmGIxEa&M-8yyb^dNN9Q2mNb%<)F@LHxt>C$8eA(UbiX1 zw>qV_ZC!A}(b8i_HmNwyxo;_njl{|US`s>tFU?(6hm%t12AuP@1Lv>QBk!yjysG3@ z!}FrKaUQ;hv-tdh5!~C2Ni@Ik{q~^U=19&V-BHg|kg<#i(wx zyj*lz6_jGz3*n<@m3PYU7Y?o7m%xP2eB4r;BxoOkM#G`3OOH*58i>+`IeOU75}kW& zrF->kcQu%+F+CMydQ=};x(5(*L5D{4EKM-b_t-ss`fl9uMb7mG9_c+4f@cs#exS~b(`jXcixX5)5$5g~kkn>h+N&<(?8@CSO(X+=a@&@*rF2US7+ezD>;5^`bOG-;p zN!Lj}@S3;dKl-p!PCA}LU#!wt+sdjc4<*i)!#QiNn-b+-?;(y|uz+>T;`Z<7%xS{w zWMQA|>QJ$J*=bC!rEb__Uz{0l!!<{UmYhrR*>)jIdwYBJN`{wyx!>=@(_Rzd#gb;w zzjdVv1Eb~+loT6eTt{HhQy#B`aNjYQ)@FX2a%<~iIRBj$h{44yK`Q+d1+q4a0jf#S zattblp;<;2!!ny0!=f3Jmq{}>ZA2Mo)G{0Xfv#d1Li-@=hVz{Sxo{&B?ClRm{*p39 zF)fT(3NcwNgwfcV7Jzu~=9?jQeIJZ*(h(?9tj^$7ISFoH<)8toZF@|Gd3AUSmfA2H zD}s#bRM${RV_q)joO2`k&RYQI*lT)7sfQ71ikJ~R;Xs%WR&YQy&_Pic5t}N-=pP&a zzTj8*YqMP?0HB#q<%IS*gJEGR+bah^pFacvO@paTR7dpiMkI{tU@&wn)OvMSdnG5< z0ZiS|!TDqo+XHI|fPp)6IjJ#d`2kUphF~#InCl57+@>cx0T2%p1TQC!`c44bY5c5p zF(ibP!uEK`E#GB1e2*(!h$!htPMvu&QU9{=^=U;6$NEFa5nj>4zgWKU_!?Bu@tlN>Rlw`Ahz5Lax)+Df+H}! zb1d|L9?}~?0f?0I6&IlgNC|6CEcOjg#a=9u$DTg+Xerve(L(k4!`zYXU0A#E7EVMv z;F=IQZ*I&&K!F0R=;xfbJ|kSL-?-ydEW&aWClTuP7}gZTv9{)dv!8C&mQAu@;y@(m z0f7e zFT_S90|0fm?a3qAiHfxLa^5`0^CY3r3@?PRv>j@!_B^N#kn#*8aK1y`66Q0rnW?(~ zfhIWD;N0_pJ>UR==@-0P31a8q2k5Emp1Q1}3^8 z0Zzr}T+$ELiaUY1FXeK~fwKkBy%--&Dc~dOXo2e&7)K}dU{6jMY^5eNq!BfLHRBuR zWZ`)zjZSPQg{9&g0^B(l9@{KaN0aoKCv;k>4g`r7a)Hq+@xr9fFc~BhN2G?EB9g!P z1FJX8r^Z{3I21gx^{97QPRkejw#o@spn_KMo}P}Qw!f0GlI4gw9J_6mJ6kbK_%x9f zTIi|JW`u&!3sqB|P_Q`biQX3A+?4S%whI?2A)CVT0V?FiUPkWF^UqjP+GoA-`O}?j zq{(y5if4cI+!wvE=S)sIWV@}Hx0eaRxsUv4vBga8z(KB2>j+9Pd&jrGb3t_xuT4xm z`OJ2>OgBP$->?qPer}_2{;0*x!T0?W-GCUf*2kttROS(To`D*)kWJqJ3Wr1Uip*!K ztCf2~Q#IW>&7*r3g5fbl5H({q1Jp~hB_@p%s=P~Xifd$ju7GtIH#IKWm7;X`h;jRu zDIX&(N90dV^|2k#%LO#dA?RC5F!#(-Dqt3E)0pLrp{xCr{j&` z^4p&TFNYBqos3rnq8$^5fKe()UzhCY7<-~{3@2}&$H^;$N;rgp0TiNFJ{YPjL%49f z2tWt?^Zl%Xj*qx6L_nYdN-z<__i*blRBeY|@{f*V&V|9bj1&ke)j=PuTCdaY4rYz>tZu}pUgOa^91hI~Vv&L5_jE8@LXRC> zsRri2=A`duzS{_n!nA=r@H_Y02#cf1zzz+TB;){( zO#wYJr5I!5yg-k*-(kyjlhwS+tQP^We&RvXjqUX%5lI25#G8zB92uM7n1gqo%3Q)f zSQ*=^kLRasmwe`8oZ#bZ1;c%GbTUuoZS$ddfeN4*VVWTdfzmY#NFrugjnE7jt(LG+ zVTn--N1M#xbqYR!p$Q@bL9^Z-04UF|VoB_&@H&u&Vo-fsQxwJY5HJryAV$hzfcldV zLoSFA_mNtuA0roo4O9%+2;7{Aco=OUG*I9}vBxDhD+Z!E(9N%Br9$>o1SpINLw3%uXmkQtS{xqVa%RgfgLy^RYQmIr zVS4BbAUZUHF(xV|h+c`H$g3uK)6H=ua_}Rb3S2jsi{}J#)fMO09A6(ZUH7afU5#aT z$i1$|#a75D{yL!uhA>K^AWUd3K!082TuHeB>jVNaVYBP3eWD-RgStfHq)`A6e_H$i z2#}i(ST!HwOKo18_F2M}JuQvZ9AhJkG0;!qC#souoBmNtT>)N-r#>8*Tc+e5szuW; zKYD_k6MtnT5V<)V3E&4ZV&2wr<}$(Dc|Nj3-cZG1P+xTs@(78YEgTZO80YFGDFICS zJ;MvMu@{CCaBTw94h62cyg0W^ISPP>0CEr*p_B_1LIkLz@L`LU1NFe$ob!WaCkFI8 zeu(W(v9*k?A#d~I8zV_9#~5r|Lm#VOUQJ`z3pYggN0&k6C=Fd$RDtTPCm-z=VawSa z^a8#4%itn%-g@q0BR#*>&p&LC^Jpc83X0xal!J>YVlavo>`47ZQ- z`f>qXqqy1S3l~#qBT0}KRGY$bJZe|QEB=ucOvM-$kp!Os!WDo}$;(V!%OIx01EFB} z8i%j&8J9mYUcyq`xVbst z65Isl|6?8P0<|K@`hj}1-Txs;NfiGRNW$T$TCPaY<7>2=Hp^vy9 zI=8xXMXQIZO%L|~X4#x`g$9#=7j=$K-5)onAfT~E9UTPdbGF^nn*68L}qgR-Vg+1P@bGdUp5{r@yH#rVx)LIhEWipK+!V>ygCZ&&t!62O*BO1hK zGs_KJ{|4Rw;#A19QVdYX;T+nEg)rI;8T0|2Kg2n5D@g()0sE;s18(|M?{cynPb8%pWHE14-^bpn-sqV#fRdHQ@Iqla(wBLCvqF(0O(5M2@ zgUN?`)LZBRA!SIwSEJiMED?UWjS+4)ZjA!#;Tu2nAST<)UA;b=cd*sySt%zTn_K*+ z9bMC0S{AJ%c^ugHaAz~o)KuK^`YTcFmWO%VoYM$_qOm~&f$LZ`)_qmhgdn9p?MN)Q+ix^=F6pkZLhfu}S3 z1YvyTj~xyG^?YE{ZQElZWE&L#@M(#!+JO-=1|0Cv^!lNV?8U9o=!h`R-Vw0(I;rai42$BDn{7KK$VvJVFTZYM{JAs{!oOEn(>} z5QNpNvB8Jx2;1xN;vD0PZB{6aSB|B}8>RAMIe%fGVgV{#q~H)07=+4@FjoWz*31F241aMsgVYFl@^P-yo zkS#fY9KAun{1Yf-Q2>D%4&_!6ga(B~F}69$zq)^^f%H(Wqy0u|mcfp zUJijFwkVvVbI{ZPq*ZY(o)i=Dl}?+RgV<%wCEUPrF48^91q56_$>u64oQ{*Zl$087 zIoE2q(imBLnlo5P5ys(OCzshQGwK|j6auTSRC>mhTY^lpujLtw06S!@8XU5{7r9La z)J_2}^GcxL!x9Mwpt)a}g$PEPg4jWr2lr^d(m`Atcrlm>4&o`XXTG5JND#EQxUamA za~PQ~b2Ytnp2vV;fIzfLZ}T>+(z1I4K&~mqEq{~TL|YbYa{^7++Pavs zF;x9nIKo)(7Z;CL@!pyWX2>@6I?dpU39Enxo`miof`msf0#FJbPxwythf~%J9cW5` zy!;A+QmU#U_?AHrA2zB|0sN(Aai9_IA3vUn0R$g{;*G?4tCQ7N5Dm45B#I{{l*&5K z`_&u}kZ#F>0lLQ)mhGA+bnRrvkIUklI?qDe)0Yp{w>UG3XHKtdv@qy6R zq?C_?xW+@3h4p2^0kp`PlpZ+(VT%L=gw}@o#Z?yq3=awd8jxQ zUIn{PldEmG%v547Oru2Rt&;(MZX>$Y( z&Fw6Y4unA9I<^ldIq>4MPQ3*=*Uh=3fRjU*xy z70!l+2WYhrSt%DVwcL`(*mH#1wTwm8X7*fUFV+&_O{$}S>IP2PE4>t34;|O?Q9FI2 zhG?m^o#R~SI_{{F>HH>q0;;x(ThR_uKJi(R@(>VAl?fn#mw~nzu}_PA^dNnvN${0B zZXI}@LJF>Q2GzZdm23^}igDp93Iaf;j1Wn;D z0Xb0Ud4aR!eo9nbBxaQ=4je=KW^5f9W6aGvL%Uyg582AHaG-$nbQn+-g-!6DltmcFqy}kL|l-urVs#P z?pOz0kgT;i2g$y^fjcAU zun}~q*{wOdD&9g@bFbt3>^boqw-8|(OKuawZV)OxkR%d3QeM$(p+brpJQgMiHXt3op%7l)7ILIbv<)o*eYN? z$D{b`+_Ne~j^Qlf1j`=c!P-Ra9~300Pk@RyS@mFTDxPD(rC2K$mW~~Z=5@2y8rB&1 zcuUYw+Q+6N*xgrS<~ z9yCr;&p<)uVHsq|JvWpFFaM!AQLa}da^yMQs332p^Q;Km$J z1*=RaYx@}T8}Ymj_xOH$=f9AqViG-%?D9UR*a@k$_MkLvyEdG6D5sT`?3=nXQI=R( zS7Zr-I)F~L?`^A?qHuI}^HZId&)E+v_qeOz?r;U8jlde%&WEAC{Y%e#5w~B9tOX*P z02xCx&zDV4F}uZGBN3p7dHPr!wX1O|PY&x$aW3xb6mHOV&Pn54#-_Oh&kYZI3|4O~ zUYldcED%cb20v-GTru&P*PEf|oM+w>%~)}B!QzRz0mnR6)8e)a;P`Ro8^s;3?_o}X zA94#4kn=N$e01a5OokLAemKm6_0WW2t;5svP$t-by=q)&EGwk~fjToZ^GF82{BC**Ksfy4~J%h*j06Qit|NPy`g4A$iaE0N>CbDBoA^hFLDg8 zE>xiWDo;AkZr?H5O#s9~Hf0aJ{DJYAG^RX@#XyP2Y+0;W*v15}DCO16xXzeATruosZ>iLaq#) z(urA3?Qo*igEpzfR^br5y9(Fr=6u*o3oY1djvj(>ffIrKm;{}g^V!%T9@f!!d;5|i zcnRXx;JB5$sMl5o!dICa>vgc;WAfs^Syhz6xi@XQrZiIZg0s@5^&@=YTkOfq8hdf* z;0^JbYclY05iYuoTsAF5{Mkiq`)ADX z>&H7*TkTWnuB5P3rvfC2X$A(kcnv`L8Se$UmWO#DW??3Xq0mDlBLpkZpFYXggzx~J zU^#+^*G=vUUcfT~)i?xCk&4b94n*Wx!s<*+zoOz-S_;lvZp|K%HKkQ16o@jwE#OJF zQcX8?0-ijEtdgBda_-Es0O5y|sJ9Yq_?nj~11Fv$NO_ua(X8z55ct%Fh>{_nfQOGJ zS%Mz+7$6abP8WZ?C;)#!Gy_?*58QJAsHOCQM3En?6@B;%$EPpmgM51L+i)ruLP4Zi z79|OZBr$Q2H-n@oD6$uH+`(hP=oaY?o|QT>-;8?DaHQ*;`SH_dZ5KHqA+160MA13x zT*M=$xTOM$gP=GU*RFVP`5ZQs&7sgNx)$L7r<_pmNY^) zw7Hl#i(6bc{<;E{wAc{H#`9d9$rbk&Y)=U{SwDsOqZ4P*zPnkuS+bFuOD0{E)LK8q z`5rXPZRCs(B&C$$xQuhAAL_6WB5($+dEXg6)6Y~7mTOa9DZ|;6Oipc%Q)jM8L9ncY zh{(jKU13Xj$`z7Dj-sI3P@7Xr!dl&PbGPykAq2#^>_GVPyM4F!IswRn+5#>g_v(FM zzW^ul?8APKH+u`%Qn*x{`(mzz2WolD_!YNiPJMB~ZL!PLqfPn54fNBQuP&L^ETF0^ zf=3Bq;^R}U#{_MF$6i^v%3QE2_rR-n6A|nM!d{DR^-Q_?-K(rZ_*Daa=xUW>It4=2 z>~mgaA`C%5LzdQVet7C$;B>a{Q>Lwhcx(n&o(oe} z0q=4Nt~#*pMsrwOThngaXx_*ioFjp8(dEwzt)%|?Q-7lSI954O^6BEEQ8_*a_zadJ z##xM@O0#RBndxF7IOmSiwDuT=eBa~Tc;Gs$A;@+sm0Sqy37$v1ihKYqie&*0920?)!hVXkCn#ktUsVY3k;Djv zzZa@Su%0tWd)e6>4*d$C8z@@Cr-9o4j*v1;H*aEF-keEi=Ais?Y<_Kk!-sGBg3j5m*Sz-o}{41Z6d1RTW+LT zXZpRhi#w{Zwuke%u*I{8bDgzM$`T^VjIV}0!DhBPbDy2L;|4-h^y%4bU?UGo=d9&Z zK5BJW3OM%cMG<~b{9J4O_~-(Xv!!p>o2pMykqqNN_tWez<0MD;rkEA!CB5LK779F2 ztQKm%C=DvDe482tizVbX*s~B$DF@#7uniQnd>+`;aoc=3|MZA94r?Q^<;;4ACo$z# z+(_#&3u0QvmXs+RWO)KIN zpw?9Yds!IUw0qrtaHeVASPMYGkrJj(oUk5FWM^M@^poCo!yUFip*tbltHVoUhpm44 zV#$DS_LQoq4Avb)+$Od(=3Mn6#;QkA3Uls`$~@zUtpOYZl|McfE>>#X-Q3NySOVm1 z({1qKX0A20k{PUt+x+NI0=~5opy5gcAx_CW=iytrO*ENc;$%^kU%QOcv+?)FiksV9 zy#JVSC97CJ+&Yfqqrbbjo4;trs>h&=a~>c9$Tw0sFJlB%FIGz~Es!LH&|#|FAjFdL z7<8+npo}4I0)U7E#ex|M^dF#ntgoXe==kuFj(6Q02)YP0N;zVpA^c67c%G|`wAnOn zO>7zghzyjePKBP(RSH$J>HCZ$BwYoG-b0b9#UmBcQ!4^7fG$mDhOD{aHZl`31NBz% zieOsp_iL)hIr`w%p}(CW45`laH*SM=@+m1JO!|YRo$qbp`A9;0&PF!IRsp`Pan70f7C#uM zr`Ij7E(R^f?fga0I^U54?U+xA){~`TUY0_ti<_r20sV7GWt$SDakeSnZ# z#Y)o36^QZaH{eeDPouHn)*cqa>0UfARybJhY6?!c)65 z(uv*R{pbzHQEoMzy-_E4MId6%`Wgx6I__Ajz-KG@T}!RqU|Xf+yy@Ktc6K~}yH`!) zjeO4cp+%3WjtaERoQGb=ZDSOp!>ZM4uzwe01W#pgh6L}izTic1?PIxLik6muBcU~i z-6>r-Y0CBB#5QSX3+$Nl|(o8F(O(~iy^Kq!^TpO955K;V%GU-OIy3tle_yHkC--w zg{D?v0D8314BQsdzA&cC2W_BSObZZAJ7HYdFB@FJ^MDW$O1JsLlV8YVqK@|pGQzd2 zFAJn79Coqc1*K3zN_;nHEG`*jDnQNiyvJUfg&BCqgAfF3U36uu!O0)wAEH`oUq!5& zZqE5Y@0;A!#8c0;-I+MhATh^@&~&2sbI!`o6{e%8w1;yy(M*+|@*H+<F0LlNs%1%kyC@MCai2koYqsGw)Sb{!^lJI*k;e{p~`jK=-=?v=X>ncv=i+G z5?y&L92|5D+3ec$voj6X9*Rha)hdp&>L7|oXXZmUTax5cja|=>r$35QpGUb~~YPh#y=<1x&qL9?+^S zF!Qc$4X}@jhtjo&i+!Qu-WDun#A{OCEQK%&+P3OlqB++T{9!(PwtH~=y-qakg1hyI zTj0)>hL4;zA**!4dU&=~O{3k3>QUzPLbN3xzCs6GE2c$dal6%0PsdGUZ0mJm#YH7F z7xc0SNl=RQ41+qcKDVvjv-7iiC{|RZZ*%E4gDa7>W$au6P{XqFtvMtgVV1h&T*7PI@s!NMIpjypw)2&_ zFYkM}1o`%7E~dOPF6r=p?+u}JVtky|+()EQ2PlzCe5ddPry#Sy2p}>FNJL7hcNUP+BL%l9~v%t9b$e5rIBixbz1gk`(A|sgXspTq9}$3j*mG;FtvUU zm1CQGX)bi$vKLs>aPLuAIi)OF<^st|H;ZN0hfI56uCZ9kIOjuWYD94cSDR@^d^n7F zf1H%?RS@Zq zUR35FbvS)&;n}&q^|2gBUS7%_IdbHhJ8*S4_n!A8`D-AKIz}Bi5;~ca#^C4Q!C^R0 zZX5cpv3*oC!Vj=PNlnGc+UKXQti9M5@ZG4@E=CW@r#`>irX4oFCD@f}S7ma(aO3BD zK9Lv5qrV&baP+v2N|`Rr#A307Ghg3}e|%_1e>d_;OERY%pV7WiogBQk-?3N7OWLQj zhsj)y=;qyOcHpe%+gaxiGyOQrk9U!8VuyWQuieJS(>?p9b$BH0#S$ys*5EefG3MnP-;?bedT^E!95$7~^mAWzXid#t z{GRVPM0T+#ripc2_z2oA6(fg1RystJ%K>;|&lhdZ+go2{9gxtkcP*Oh93k%d*jy_F zY_6x`2h@oM2d*dgd3gNT{r~OWPIpjPUTnJ-aBUH@oL}PuA_Grs|xXPZoK28;St2IlCCYWN4Mcbw7A3gDe`J zsqQPw9$L0Tl)peAODmf2yowdqs67w@nVE)p7OG}q_;AGl0!suPR>x@qp^ywIh7iMI z3#B!o?Itens^|5LRiRof9KaWJHYD0w9@Ooj_Zusn{%1wh!SKyO%@wH*4w9|0yZ z38ljb?;=zuIp0h*tOU@eFd-nC9Daf*F~*uhcLZcc?3|4G%Ak&=QJ2SAN8qsobv(NY z(OWVw{6j z-bEhxe$IvC1X+LJ%zXD#-*y*R)|B*pH+4ViOb&E;_;zXFpXuCD*%>(SXLZgvN4w;l-^p&v$E2`yRhX?c+?r zyTdr5F4@Px zcne#dQrnICvYc8L1QgSVpDxMh)KJ>eKCn1m6B#3n9E;=hzUMYy6#Ff!lZu0YcV8^6 z3^2RzMyR$Pn@R1QJAfgCz9p_s*k2Y})v+x5L0#LioAS4rvjS)Pu`kFkipXAz#R?}Y z6@9j^i^co*OXEQKof)&(S+VWrrbg0IUe)3-MLDy12tKNNla;Ps$W%E zB`|eB^kTb6Q93*O)(Rq0yzyAHliUHg7L&cTmP{i45gRA9MY14z_WIODhDbhs+6X~9KyvKS$qAKG*7wjj*=>g{9nykPg{FRd*xmJh-Ilkk-PmV z&+pE9&L3hMIl8AD0Rb75KE#Xn{?+fLw3K#qn5U#w*%wFK_2nrqocVn7x7{R%f{u#- zArqhPrw={nAbE^ryZ1JwO$Gr`RE~eP5i5l@-XP*2GZ`$@X<^>PX@f6NFpRLUYdqN%^3;{)1$zifNOwRSEi-&y^?V-;! z5M7}$E#LRfI_J#S$0qhTa~(%;63sRih)gUI03?9KyP#85OHFofKfUrBQ~JB9K%kB8VW!+ zjMxB1Eq>DKZD!#NYqoDu20VSqOp`9j99Yy+Wz}M z1gx&W5l+7xYHl8NE(!rV9OTt!VoTP-&?BIutYQHA|D}CE0B=At)r31H%_+)-3Xs4A0T@UdUgex%s)OAm0;!yf zxNivH`Eq((FLZXRe1N2i%Fd+omTxd4(qW$Rl;?zeTF`K(x|0e9f)YrFc8a-PF*u1R zS)Fm-UW6V|KF8m=3>9gC4eVDp`Q)Sqzzwih-hAfgd7saZ<#88)bP#ks_1ih~^-cQP z$y+cNNhkdJKGFTs&wQ@`@2kjx;fNzGpG?NoyFSmi?|qNjY0nOkn2*o^cb@Y6bmsZE zYa4lLp01=W0(i!fo_ep>@9F1boE_&^G35uY?`m;peg5vLpBD?ZysOo6T!$&xJ^p+9 zyS3-DZ}L3?p+(?f*dErVo_ap?ur2R8;@QxZA_ZqU`>7v4o%#6*?;?+ObBDlu3(AV0 z^Io6dJL`3**Om~%g6n^x*mTzG(^=0a5|M)~cM$-?R5COMnRjPCA7?&4k)Y*)TZS}L zjpsNvKsLze6Qiip;W-^}IQ&S|*`1J1y8+_Gr)g*u=ZxW};f$675uHD(*4(vj%s19t8%~gPf{87JRCe7lY>z zVM5eVrJ#Hy0YtJb%E94bQ0wVgZp_!`-N+6MCp&c%r;HHzTJL^rVDZa&F#^{J(3lU1 z5rW|X0?jcfdCtaUO*)@*3o^hubl%&#s1!A2;`yJ=OOd`(DobJ>U#8q*2DH^RS_e=W zYf6+>6~rS1I<1cC49mjnIFC$um}0Hr{D4H7FI7!wHuLhCFr zRW69nJO?%)v!rJV>lkjEub>JDPAmL$p7^ZUrhdHUJfZm;9c+t^y>_8uVw!YjGlTRA zWX8auOECwsP3yD5tYU_(L$mL)lkSyZkW*e1`42k+PzCs;^=R7*-_S2kt7}05o+khi z*Dz7mJx_VQkwYz@=)eHHwGTeu$4}&8+R1n@Iyz6)rWl#RLr-}=zRb47jlMs65pmN* zD>wJ$KHukWJwDAM-$ntE8(Nj8KJ}E>Pky*zj}{3w=D38k^-<~by}qptNHTEmCr!+` z$*8;yKb`k^eUN;#S)}=FYGIs=lmWbtKb@6NS0|&6^ckGhb*csZ+@9h`+|OSpvZFpu zt?%z2;_vgyndf`#U_8uCtPY_aAN0Z0J@51Rv!{N99Nn8K`rsrM=rVKqsUIKj{rNre z;2=2;U<$0zi7DeMPVIZ@gQtET<1Mo`RuJmGK1_$KI&RWV!UF&^Qe(YU6L_4-R%^ab zFb&l`i)fDSj1Kh$tUfB=4r+qS7K>yKq7Y}dj8-@--P5@azprqyjt*%1wL3qf8E@8#N4c`d{! zgEAo)n+%H285}dH6oyHql=B4!1GaQ>NV3l(E|C5AVRn{_0LT2_ zW?~{T&B`z1pm$>t0QR2Qz9;Ipm`}fcx~7Esc!f^0a%& zhw7b&wtxVt6zP-t{lG%?hfb2|(2PHLpC7*YKGCfj2tz3io%4Br{M|+5$lvTJ2ASBQ z;gHgw{2a-#cLl;7hzb;h^@zCM`^TSqYCa{ly$WbY5uDLTf4Xn|^Y?``=R;0DXgc0~ zAR`O!^X=PH{q~hN+>5#yAW(y3i0AfgynYuy&>q^Bi>Mp)r62PSUjCH*K6b($w$15K zCHy>P6Cy5sUVih`++KN*Fo#{2GKZrm5@&sUywB^qwWod1HY|+{&Qo{|ggtM0@7GUH zd49KzJ#O5UErBE*CR^|G`R()l@Qvl%^dUE8Rp-Mammj8R<*eu1Q(xaclgG0`xFP># zeB=~_2+0fE>RZrbGuj7Dpm6vLXsOFYjftV^RyJWyV`R+p_dKYbXl&Ai#wzOAn`6$c zmLAehZEJ0&n8Fbtr@3O0M#vF;Y8!)2L2$MZLHFEk->z-6i}4+I!pr28E}kpsWWR+x zc&Xtg-*C*Vg5s&2^U3x z%FEr^xv=7zGxpEM{Zpn*)PpmXT>ub#y!FA!yv(Z<0N-9A{Fi($zq!iS!R}Pa0M^ey zp7K9oZqCsqX8Zu^6!Er=J@lOo^{`cQF8hu4usGlS9<>j!v(OlPb0Lu|T zS0FUPM1y0$1iIb*{XNG5D{#~JWGam@>x7G%e|Q9NAKu!z=hQSiG9QU4s&o0ZpO(+`cZV z2rA|_K)|c(uS9>X!%Irv`aZxd?|-s|J1GtU|K{f}dUE|bdM zvFu9yocM`I$fyD59KeOMO*?pt@7Rzw3m#lTPtVQduBQIzNH!cSvJdhIg{n1-S$H{a zp5NrJPG>5IQ#Khq6C=>h&Ch@h!@z;NA?n}kc5?LS-4{ufPNc(FY*^}JtUskc_AKHa z3q!Ws%~n3$KI5^rS9t;S3F?IPXZUIjl2y0Ea5q6)KZE0lo^?-KTXU`6h$q+}apNGe zPESp9V3VP{f6q4Zj1!zXxp-7~2t!l)E3i4gq!gs=6rTB@mV}Gylx)G=f^?(u)>mbV!|;T&U>31gK_=I zZ3o^fd14rpLC%FTsW7HvLig(OiH-q=319}%)JAnkdm$%*0$FJN?7RZ#93f=kjvNn- zMh2mtK{SO@Onu(O07L`AZh0jcIy7~Vi}frXI&+b6Fb?q>olFu#t6Vm?8w5^npl48R z^SS`%IsSD37Xq7U7}FpyyptyJk#3$c5oMw%eWLCZ&s{Z1`&ls%#SiI!-|xAjItm?> ztsFlfZV5MdBpYoGbe_LLW7`iFo&f@eR0oGY@)c1OE-C}{{#Tszmj)lE}$6ksR%#s4?n%PpS0Y&C zG(D8#uj||V{2jKv#p1$lOP``}^nHH(?!EoZcQ!t~8qTAKvPJ0V4}BZo{+OQfW4Tdj zAPgjcFnm`oghaje=&-(cGUin?zYMrf^ zvGl3d`b31f=kt92+EZTNX~L1w9`PVgo2LKUc<<-;KP$Jh85v?@l{Xp{Ygak@`96O4 zULW6lx>#Qjel#a#wF^M#A1+&SwR+ngi7R~!B+w2aNHUix>9k1+8#ifB%@?G5YfLuR zvQfNNpTkAdxjjcmK1@(13g#$l4b>s1DMl@F;~B-_TkKkRcB>FW(1kbS!gSWHuY8T0 za<;bVyAOaZRef6ojQy;IsS^f6PB+J_i=?IwN+N)76c&OUX={^L(@w^88Ex!~i~v|b zr@!00o)x2sV)CobvTA5uZF;@*&|8lAB%2->A1pb!@6yybuo~4P$2o63BnT83G+*DO zT*Do9rH*CMJkrtH0X?L-&^EIYj&o6Tq@o>0WKM1*a{LiEeN2M(%=d`A_u4ppY;WgP zJjYPLRe&BgYs@hOy5PE-l=4aUQghkf<~f>ciEV@%aj^-Ab$5kJ1Zu)mi*Ol>%Ig4w z>>w5+s51jkqa-kMd}$*_05gQ}1;B+cu%ZLR27st{D#E<_S*=+;P%1Jc?S<<|Ys8xC z9!609yT356;-5Xy2_X`^Wk*{ljh%h04=~(v7!9o4fiov3dl>i%U`C((HlUOz5l3v0 ze9XzWAdXEJz03phHL$<{H5CBLw?4e}QO^gM;mB0T-+IzajNmIJc##-Puer5NIh<6i z8dM-~^0i>-h=~Zv464=5WOG0wj6#T@6^;)!)RD}wIA7(Quc4Y&!BO`Ee!EO&+|Txa zmFO*ef5G3AVH7eJTFk-baQWlg9>}ovIp4;QztV$kn_)Rt716~MiJ#8r`S?xZGYw_K zm52nw&-3H2&ho>5rys&l1i7x8TbsKd-?!hEoR$qY_wgetiV%LDU;XZV@;!eu6;x?# zSsOhFocPJN_VFtt;@%JtO(&BPKJWS6d-tasTFls}+qNRZ(Dr!pt@G1a`P{Y;mXE4p zG==c{P4hi!(_0Py6yA5e)f4@KQYg=Jqk}P z!ChiR*?UQGGms@W6+sF&Pdj!kD zoP3+ZU~c4-2p;1wDnHmQp#zi~=BoPabT$ccb^z`h1>|M>RCu;2P+?f#h>0!>7w``P$mB ze2?N71DuxpsHHeMrkXd*#Q?s@>wa+~Xv?a0o%Cof3saoz;j}X3TK@oMer)XwI43ew zuG5O)Ax{P7pH|^}06zVf@iE5|p_F>2q5`b_G9DPfgiJIO&K=`ebh3CQpG*XasM=~N zD}DOIl9$Uss7A2z@~^HYGhvgzCR0`pXq`EJa^A_>5a@SGmOJV&J)MCg%w>Wo;jITw z*D(3!bwfctR9v4tAb*8Bun8?frL3C$nXd8;1Sj-b{fie5x#k=F(h2Y%8x~3J;P|1f zRiqB91d?^U?d8+fXSv-lobZZqsx_BiZT*Z5re-1xFGQ*zcG)TGqh)}ZyfJQYUO1hv z-b!NwgAycT2%T0p*_$~Zk$?j-_!>s;+eR(w%xhu_1_wL%Hh%oQ_s%zrx~2{@S3$*q z9mJpWt^M@J0jg~5j}TEsW0C(}KYZ`=etdV+hZ^whvdZ);D<1ms{`zl{+>5EHVBRg4 zqg3f1@U8vuN6(HtM#qfmw+A8vH~ct%`?umJB=O3AHlzWK?x8BcQ=Xr`we#zUXSW~& zQ;TM+h5mkKe*EXxJvG-jBHN7}gu`Xt6P@{SeQQ7bW^F!h1fS#M{u0vogm2@=-+aE$ zZ-_KCT{rqnNQ}JKx4R$Tul~+^eSUja=QW}S$S{OzOqZu*`Jrb| z=Qb%TV?KU3)y;8}cIpGfD2jHq*8R4D2e!Qm$O(#iZNJS3qs+GUN@?7;^*McG4{Yp( z&X)gPw)_qCkuIE|%hDt|uRu|3A~f=7ix0x!!NcyUFUyFxZk#=iJUC6U4RB-C*<;Jx z2KzAWiJt&PKvkzL0w)QquuaBcnPD6wSC3`|8tOcd$0w zizUMlCHAsoeRTfh4Hkrr27*;I^T*HY$Q@Ia{R=~pmXq{dEZ7}1k*GOZ)+iZF`^KLG zc{t4(P>kqnHMU|)Uk6NF_NUb{3qJjyufFQ4gK8ak8J^CS#rKQ=w;!P~yj|^2jtvc1 z;l$Xixi&m{wK5Y* zF9(T2qhV98-d@;d-#28x6rOy(VVxc92U;`N*5gKPOmUs;=##5F*bh`Gbi~N-&|vHs zK_bu4G~g}qT*$^Fb>iX?*Lva!9Vmb3m13PESH0+Cmfw~-(`vd1Av)f6K*dt67X(St=(K(& z{@|p0*rPO_Uacl!6LzpVVJTHTZbZ{jl>Mnb^;HS@QRMotD)r=B&iwc^Hrnch!zHE4 zS8gw@O|3nRxw-SvJVhNo>_pMVr+hosIB_b~s!{?<9?{KHKaOuJXF|9JUbDk8-uv6P z?V5_HP)b<=;6fLl(v|3)R=u*~d9L)H`g~d|TD~QrqI^eGm2S>_eTyIXkaye4fHIYL z=Idi^;h!SVzt6|VQ_p3APxO`@WzKJw#2NQVS(+<_mH~CZyZ2q}D69IN z>O|FX{Tx`_KbOQs&Z6>aK{A(O9R8hbGnKmKmQqtB0|Z&WpMrdz$~F!toiL*2-uf(Z zToj=jo?JZ+KtqpiOLwKcLp|AB>hkcw{hLGoZjQ1-@Qs`4^WS+>m5e!CS7vUr)`i@q zTvOUXCUe__B!bPrjNfuipjac&D1d$^AFC{;Rmg4 zt(&Oac(wJ3V{us*e&tzXC19DjA~++BeCH@zU>Zg}oKvQNQ-$VqL?09WP@lW<8+rIf z`R+N5EqAqem)Z~vX+MWam$GCHSla=^4QyrZ9M<_#X?U`oD=xM~S_XIo%ZTUw=!VMM zN)H?nh*pR(GfQkP%}^yzX2k;2$-4X^2aYvO5(}jaPzQou4V;5duJ!w8$`IV7sjvq> z@bu~=c4UsM3teqb@w3%s-$!SMPsT8c#8jd+*JbW(U`lf@JyJ~%PVRwx z&UN{j?SF#l>31&GC-y3QC5%D^Z1ZiWZdgS(7b1}7T`?hhDFT$~=Va@0Z=bN$(lFZ4 zXZo~VfxMTMvu5V=)m_1KMAc!^MLJ-{2moJTV1R?X^-2ZbhmhZi5112rx5Xz^8zKuh zuHPaT*lxl-0%?c#RZX<9(bR;H=;T2}RLI3A{Zll#M+ zXHR(bjp4GY$Q~yXJ?f(hID|ElTCBI=w^%kJ>y9q7=PlC-G*?a!i{%xV=LIA}i zhlwFMyY(Xf-T**!Z>Q67LiaBAjGf$r`)^Fs73y&1?IVy~yJ}Z+xwL_qeluFG5^m?_ zK6l`x=bJ)v)qzf0$whHhp82B33IsSY~e^2}QG5yj0urRiphvgW)ahW+Fj9CIoI2)@gOT6+`y-XvFwbrkeLC_0J{tM^>vJS8KVs^zg{1EO-I z^L|~b0Q4p24QH+G7@QtdhrH!CgZxs1!?RaB*iRBvJ810o4o+G*%Hc8Y*04TyQe${g zynqdu3ok#M9gZF?E^YoYj#kpt9m3mq%JYsJ>B(onQC8a`df#{`s+@bu*O}tQ8r(84 z#&lYXa}^wp03KqZK1Wd)%?HY}`eE!Gw(JqZoq(CpTutkfU>Fm=?TFEqlHILz%Y6U% zC=OE5#*z*NH;Y~%O4HGF`w%JHs)oQt4yLL=SamG|kEp2g z0bc-nUlUH#uxLUNhO#1{p8lk^F<4|o2_SUyd9w9}zM$wFPYUTYALsW18z~ZCgzEr= zHR@jdbl+<`$>ptqQwKuk#xkwsMS(R>v8}0dbGZ4Y`J-AauQ65F`jR426n#30qN=_! zoXf@@FZ20^zwLwU+%nK`?xnhxL)^j^RAJeG%%ghxqaojQTer4}Tp;Xb(zTh*8DcHc zv7s*GVt6SwP-lc3QLfAu&7m`94qS>X5fdI6Nbs($)qzSXsZAe9=L^!BSFu{9#r4*c z3^%Nw!TOMPVGco9d46Koi`iG7oX$4uGXZeVdQyC)zWF!z6**5wp&Lq(YrTF$J4H?{ zsj52KqujjauDk(7&RBd z6p7;)q@wy8&2Pe$k?GFuwXVsLKb3Cjope4+~d5T3o zA`IBZSH9ObTmbcE7K_%t&vYJ@hybGo!n_4GN%;|A!Hp_gvGL_Cf1V9&bPT)g%vQyX z3)p#Bo&^!PLPR-Ny12lO{Z+XiRPo$vA#~9=S+!%_CcM zZqjnHDL#gEiS3FX*nq{xOtw}kJrQOK%8)gVVe_WWzPTBQxVfk2n zU{<^A)wyL{!;LWX&3;|~GESoX8^pl_fR`!2I@jM+n|TM_POf2npfR&*X3F^!Xel4n zAN1Y(R%}=3bYQLrvGo~tw&fvHN*!wSqc4XK`2`X7i%f!*sn0wGFqpO8H%*&yH=dT)K$r9xe>pT zRvz&V4)%bUOHXC2qjpVH=0Iysc_|Ge$&>vg0je`m`I2=i4}j`^DNpr?^V?E2dhG=9 zp{jKHaVm^>=g9=)A%~#zw>4ICo=$)nUS;64cD^b(g}{*`)+B|8q>0&iqN0We}l@Nbe4nNy0JWh;V9oOj(cK1WFd z5fw|-GK{x`H*t7KQk3(EE)tCw^85SI`%rY*s6ON(L?HlYJ``MGtFfIFu3TQAtTM2; z>~A$+70ur^J=b~DElR0VF!_8dy2$}-FEl;zLcf>!f=a0;+-P7)nYE1Ohs@_dgL`7) z;Yjki`4>h4IQp=gvlmnHv9~7aGCGfxCCZO?N8#?9MqA~iK30!5QcOW(7@5^h%wxgE z-n_Gj#-pRrB|58;x@9QJbz)AmodMn=(LhIYFh9VvE9x6Cs=7szZAmarx!+T9;dNq) zWs~>o9aUz+O&=dFNDrBo=4jgv;&R5Vti-HN%kp=H8e7(V9SG~3PCuG6XTJ;j2;4?19E;zuya$Jmg zZun?6aHExtHU1IbiwnL#Gv2jEXLBjFV48O{x8-Nl2D1IirP^h_7K2jUK;HDAzR^wh zUVk;*Dc43WRlSPO#LR4%qpC;rN(t;;Pnhg*--Rt%tn7FQR(+rE2zAlNcbaP#6-uP) z95JOg_-tvK7UL^{>|E-O^rBi8OJS;Z&03=>>-`zJP{0)cQ*eys9Dy(yFc5nA@+MJM zlFp=OXqbP@LE2A;39s(WR4d`D^D$4k!Wf#2xG{ZlrokbTP6!BI*IN62CE~qv=!d^(LaAssNlg{zcKaQ% zi92xYU;Mp1H!4v#O_#DomDB%laMg`^^W+;?-cVn)jch)odC4szv~x|4}Ddn zPE-uTJ}`*!6^c2VmC5V%B@^{8><^c=ry}i4lyeUl#)O?L*yU_a?Xn~5)Y0*~juIW& zQsP2=?BV)#`kY?sdbGvd?4aYL)#@xzw#fEB5~nE21;Tqq&)*8Io3(?@s{h(9MDADz zeDl``TQ~*6xVqp-1Djcf?Qm(Vc-${6yMZ5lh?g2_0%I~dPjfOq>(<#LV>Wj{Tzr$r z%6E|?$;K$vB8m)kYYFa9{JmB82oWed;cluN2Gy!-SQeB+TxQ+c%E?cvl3MYYz^%D6 z4d4FNTmR=J^T<-Z`++GQfkm@*!x{0aDFB=aa%`KA7U08{$_y747D9g|ai{4_8b#<- z9twV`kyzqom?SY)#P1tpi5X&M=68teJOAztstQg3RZWT{o-}~T=U?Iu233M;e95s= zCvt*4qoh4W8Y{%FrZnNad1A+zJD*%|;6lQ(2`epEogw+am#6vb7#cjcK2C=~*!AU3 zz)DCK{r9u#WR&s**Zh@qr&>CwU(mH-(vN8+>xiiDTF;yit3yE5O4v0XECVoMfD^4u zg>@v=+b}%J$!D6_$)xR2=(}e4+R)d-r_ax-IOjk3jJk6hJL!>( zy4KC{PoL)J5%7%8?(^n+$%pFN=KPmH1!EFcWMJCxR)rKCs`ZdjDeSw!cA zE$|?s*kvC479-^ejY;roX~ebT!-z~Gl9akJ5-`kun>b0dfrw-}ktJ>;DzHMkTK<3} zk%gwc(isI9Fd{AMC;$P;RnbL{>rW#!FG}{a$~x&vL;$eqCheSX zIt&aANkzDY9k<^4PSM>7)lys`~TE+eeDr~9hRoqX?+!`(U* zm+hur%k9Nw&A}X77$+Lln4~KTbKHBDKEe))CiG&v=aYa^u~7_2vemL&Z0v2dKJ!!J zz&kRZG<@PBEObDQ*<4_o6D~TyP6Gk02;C9y{TsB7-SY-#BS&zcrWVW|O=egeXJ@2@g$Ut` z5;3MR(zOQVJZC}#n9r++M$`ds5K3rKxr@>$h1ONk+0N6Zak!sdHFs;*e7FatD*hvQ zAlbZ9zVjL1G~`qw6e8^@-0m2#5NNRNK_Q8tf@R*|%jRHXm0l;?uw{2Oc<3k=- zjtJ?@ahA$z0cne5EC?kBxQrcYjGZ?pt5FqG4TmOYGm~rm_JK3KgwrS+5t&ogCtcVW?@>bz z3_{@QbH|h6!ZeiWsIt*`s6eqEEkojNri!w;g^Z*~V?YJ*88&?1OfldoM9t+60Kb)_ z(dH<^;&xV9xP~OHXu*o4US=dW-P5Zv7{FemRUEGP`Md-ObAym(X6R7NO=B^`_eZTG zH#{U|Z@I%8b7Yf$p?1*9xs+Mlq7S9yV9w89#Ig~uYr3<2bvG9H#|R7jgKi=Mb;9|~ zSadwqa4!j;ehCe={NibySYUuxbfC5!nU0{3Mi6siisE*^1_F2aotW-w9$yq5-t{cP z_UxRUEuZ=*#!+~pduEDEH27>P^iTez;C_bM$Q zGUd%&Ub}n}GB!mS|6XIwq;%1#7`*X1do`P%^ffQWuX7AjjPvS2m8vrwh^nlfT;YZF zQP~>208d!RD@E(TmVNBx&TUUSvze4X_6&x4+wP_Q=&{f_8Oca#4S%%ogkf!A_d-f>GDFTu&OW~me@#@=a=3B#zB_6;|bROooDlx^~NLa=| z7*TbTCIAEU7y#Fnh(rORfGtthEk5YVs0dWx)^9&IS;HxN(Cm#btwq60mSRe zh^p_;WsZ;s-b$X5J{XEgEwpd4%ou1*dr^WivT)h#LZ?x6!QV)&2UOG&4C2yNIN&=? zTJp&Z^m3>w8Qf8MLnO|dfeDs#2%&QWBm?ly#jT~RRTl}Xo@*J+BS+@vdK!KsbneR< z%QyOd5coa{J|}aogbyI_;RivdK7!Uwvzx;uWm!PfvhLjmz*0pxbBIDlMJ*BRwuTK6 z)VPT#j3gqFHT&CQE^5&fWNri+lRL0444r7;UjJ$m@k+2ef^b6MAt3P+j0P}bwSs+x zrSHy9Hd=4V+O=XG)z(cy*vsu4gnwUO9U&k@Pfs@-%g^!|x|#}?;4qMawU2R*+mt+X zo`h+^w=4vy(VMaWgcKG|-F@%uXKE)mB|w-S9|(vOIQ$YxdQuK4d_0zI*+~RFp};mDH%Af!(l)E2 z7jkaK(~Es5Zv#K@>f0vln!Acg2HZgY(>UC610wkGZ3Ch&<#`;{&fjHxW|xgXBceP3(c6#4LP1QJ6Yyl-Z6uv#sk;?-eS*&AVsSZ=)LA_Of)ge*@K-7YG@IWW0 z4|R$t4&1+vQu^A33|+Ll{=u8JXHO45nm+>cxE*;R^M+CQt!LlNplal;)v3VcBO>|2 zn_m?XRlJ4K5a=|QZ0_AgNicDQG8?lxFm;ZtIZ?RnF5b#b8L;Qlb~ojSxOzeE z=6Z(~X7MES7+WYAnK17PNu&mgmK0vgXGaz;>*qf%^h&@;Fa*&EkFxs9=~6yjja7-n zNnIa_A>idnuwMNn>y~=qR%tNX8s(884e{@yqxzsZnveOR>j3Hd(!3!I`!iTK%X8C9 z?Kj+Z;N7e5`G<`#fLSFXh!VsMETrz+-M9G`#0e0crh~-p=mzV|8|E>HNnRH!<%D+H z9()Wb4|D7d8iTGRqP+~|05k9L6Tt(MB(oVexC&FCfgRX$#$*5pBKsEBLzpfDgghE> z(pGL}C@6*i%G%i)@}vx@1TOiomYQhdiAVzk3<&_P9_a^lUbkrNMSIV^MMFWh2 z!9ZXGB3Sap*X#oxck!w^!8LrxOCdyXv)?+h3}q^_(r!@HY?5u$tw~q3Bx8A|@cTpG z=MeCN5LUgNX$Jl1xiZ~>j4Qt5hOp9&zW}`%X5L26aM`f!f(Z_8Dyi|cf zCMf3mkJ`||wWrWk23>#9g#W$qGJsr=IWNc|nW?A4Q-pEWIrr_i$)3^f90!v<T0 zJ93jbax`;jKlo(l#JYh8eRI1H>>nmbjC!;xPE%@qHQ9rN-c9%X05r9SEHYVfA zTLxi;hNzk*%ST5>00RJDg>Q*y>GC#TzyYFGRTi^A6oI3FPL8hp!P|rI<^CePoxi?% zy5Hbly8Q!RqR3ZGtYc)u7`zKa%CelS0qe9K@D(};VeZ#Yvpc&eBb2!dme0HlK#3M# z|A63Q3G^T~w3~Q`5dh3XyOWdV^N=x81rokGJYN<7n1742HLQv!!Ow02fF|M=7nc`I z;}BfEZTMu*UiC!`nb0N&*D*9+X)i)?F8Ss#AC#@ySQ1#4BkGvs;Gt(mxd0cSN(n)O zbsM*}iMrUl(B2#186!lH%nW;~Hhj36`>yLw)&qvvz%8g^Z||{BFeboAeZq2Xb$gzG z1sHh41d|{0L3J3@iR7rxB*O^wy#Y|n4-I@W!rcc+_<;sWbKe* zyvg$ujk_V}JemQ9dMyr3Z(;7*e;?Ln;ynFPSjnx&VV0d35)~^-?#mX7mV-S`helLg z)bdxNho+ZpM$-pm`cDDYh#R%+i=>b0P|*iniHOQm*~YmT@ljAc<=RA}dznE)`x5SD$3#C5q*I0yb2%dShYoGE3uUlVp_y>ei$BR+^X@5tNMg zEs`oAH8*KQ8bZxeI<4MUwnJ{)eWRFb_u4*~*j&%wc^w*;GP}1LTSFlJK(B&FLpVaf zs{kdigboWJy@#eB^AV=Fal>3pfk+E*NDH#CcpcuA$1PDv8V7Npla_im^@xmgd8*PiL(05deslXwxxqRJJ+p!Jr4w3}PS`24=Yo0o4fu z-k%#LF$B!$+4Ve`0{kg3KU3%rqV+A!({HO2oI@uO?`xImdF05ROd)G1npB zE0|&j0u&h7yXh$0Peo`jOx8^e!pil_`K}vFxZckMdeJ7KI`W)GkQ`XfB?2d9X2W2V zw^~knN!6O+D5frzIW#2}i-E?9d?zbD%0VmItg%JWfb5R09Pi{u8_Bkbwy+s2h30Z| zc7JXMq8{Hu;Alt)h>>el;du@K_&nSOLsDgfx$5A&j#T$PBsJ#VoQyUeQOn#JpM`8i z$YxTO13$U7a9L;a2!9-pI{_aI7;u)90&0$*ZZoW$$JmX8lBVFoa`@7t3A5OV6@ zu?7#}ozR@+rdq8UXDjj!r#v4B+UdnfysH@^!fs^tumr4hcG=`^(*^L{-+TaYaJlI_ zMV75YcS-b--T>Kl!wzrvtq$1yGo9nTr{25znFT+P8Z%+H0W%0{>2@8*02}$3?~G;( zfYK-CXBTnJPq(%bTe3m2A81RoLBp2gzFnq!s--Z(lK5jFf(658`D-3z-EVKtJobdn z^+vP`m;%7ZkqKlxeN0%20?s*S`uIxnT!p!iU6s6E{o0n}SXHdzQO4nLXm0&L8 zI>GkMSva>C1k$@tkVcmMJvaa)RYb#KP8_k>3yCHv(jrs=>rOZ*+0+R1hQ$f!7+s+0 zHOT{y%dg*=h!AX9A8|O_3sH`$U_LBLXS9Y=Gu^u!YZXxb2U9{-gx26mV2=`1lb*+u zGzk?8_b2zmzz>A@7w`(uO$# z1%V^ow(3{Lgnf%3u7Pct39pzq+)2hDV)*l0ptbaJBJ()TJ4z(zDcSW|_YW6&u4C}3>WFS+Z$205$G<))^RJM>azAktW) zn~N4Y{dR;~>UBmNhN`eJEm&6*Fmvvai!`nzOuz7v7X06*%4 z)s|OYq|^L>LP%oCs8dBmSsk4$FGFak%GWs}Y+6fRIk3|Hh;A0sx#K_%7a^DYP?@m8 zb(?;Qua-)O#&8IBhD3Ph0U~|edBc#)f$-9~RXYt;THNJz#pdZllP%g~#sy-T!62gi zyXB!frpkTG<{}p(>?Dy=;ai7gEUgG6fS!DHZmU}ZmJyd8hZooqnv#8nI|YH<01Dud z5Pjjm5`Uvnr1Y05%hLGaKv7E;1V}<$15#OLG9pB`n}3v(xDODvDH`S?uI<<+WGk^1 zY?*A*+XS$#y$3q(RbB)T@Y+*A!+|Z6nORs)^Mr*t@!b9M8_zkaij6$aTFMQmR@0%m z#S2`dl&b4BPT6+GBL&fc&xhwPC4E?GrrnTyQiv5{1Ry$ltooeT(NIg@#316Fw^IID&Mlk%Zbk1jV&zo=u|Q@x2n_-w63aTI)aFDk9}Q4kXUU<=VYj_u;0P5IL8;YK z%enqr%;=%%gH=XTDW^AZxV>ZfbrTc0ts}~wTiiL^_h>S=93Ur;CLiw!-k)o%$mz{v zd7yV7RHqK3_3nIYXj5H+d&pt7haPI?j!<>uLe%b0F|q?r@PtknHylYCi(6mVa+-&m zIXe+mBVONH$IZl{)bn%Sn4(MPqh=?{X-M*;G3+4JEy!IIMY1K~<0Jv>*0LUXTsN-M zv2yOAT#!DQqp+M=!(81NGbd}i(_-bI^Tb%*)@c^}?iE-_6J|rkDYD4`u`-#VU^V00 zLR2Tey=U&m$5*Ssf@@sF0&RA@tLxQi0dI#|rO2CLC*BYLzJ;b4ScyfvYQzk;6K7hx z)N0KpN1;s9K!+`=?-|wHDDOkyo#wVeCp-ifx#H>sKB*rFFEx(9z()~`0Y=Qj3|+%x zfKTqum2A}zOp8-EyNKYct&P~9a|>Q*iy13|b= zHavxx);Mpce5lb}P^pLnzyYq5QXZXVj*y4tGv%HFGN@lzIzWi>AZeq7q^M7ua~x zVjOEO$T{w-k`rj|wM-^D3a~d3t2d0tWft?_%Vo}IRzX`>}!jXWY4qoM2qF`G` z0N=J5(s){$3y4V61C(vtty(E7@-fIvz7M&D%lxV@r0aI0)!G<#$R4yJw7`(zv^+*! zoC<0aoA;w~QAU(HOSe0(z{|7;e>hk3Ox>f1Xb+#Ypn|0UZ0!x-x5#eSh~TD8!3eZ(V6s4*Hqp-=R&L%{` zqGl@w!-9WQ!{JLe5`GZ%u%MJ6L=z!j+e<(+?B}UjsZ^%gNq!U%sANJZl)|4TFc7T6 zgvb?^RD`4ofT)UVl}dytm`JCXVzE52*LiX>+8F9QQxyi0^539eiltb{;tO((y~UYwZ$h9azU?yZWMI`dzPu(pf6zV8QIIynz9FTa%4VnWk2TJ2sJcFG!21GgFsMMDvaiXaNzULX-xg`9JU9`?%|%@Kv}2h7`dnv;K*tQuy>IAbM0agyD zv@E|C<#^{R@01@dbo>rQo$&S@30K{22Cg5${mR3q?=W#bztk%V!1sgr9x~d-B^yv? zx3!!=mw{VsoAhlJJK>a@@nWv|>Ds2pHX@tChGPB8)+@LSpp1?yfj+)A&_?8(V8EH-jl^C}@{~-dl(84&s>4C~y0ioaO7**4 ze=Q`Dc;M18QPS~OPr3Jcrc+sj9;iyT82??aBx^iho7mM?hHHFznhVnzBH=t!s;naw zB+9k&i6hEa0Y3`oE$AGurtK1xP$_c_;W-Y*CnorJFY=}tz&nBt%c97QT4DX+D@mKL zei;{j5QxA#NIpngm>ZJV_7KJ9 z%yMwJ@)%VV2V9Ni5YfUw^crIzrVV6pz+@&@)ybBZCzE0SFhngsxv}?RIqlO{X$@h7D@CYTs5gCYy906BY8ijhbh{w_TRIjt7ia#8M3#3)8%AkRoNFn9C( z<(H!%XGM)ldDPmq*XM0F6OI9?$4Zh60KhN$-B{fcYHwN1QE- zU6Y@ThLn~s+zdpdG(KmNyt(yH3|iYKJbxwnJgb@u?@&J5?T357!N)|Z7h`z))anPO z;wLatLB=^vR>ch3F5 zhfqfh52rm^<*}T5^ni&-5avHp_gJ4bm+?G>ZPLXAU#@Kg*UP@F7lvzy>oaip5${K0 zpzks_gR9L_gISC-K#ip{!|cOu+&Hlc9<=R>YP5i8vyhzdh*IF-d`X!}9%Qmlw6-5a zh{8;P2cWp8GISvX@v6PoPykRJ)twlDr`5YZmmt9H2`NwXWCQ}dUc6f6Of4$XSguA8 z0983p(%}X}lU~=5<1)ABfP}+~s$zeGr#yIdo|7{TMVSvr5CZ#m0evVR-N5&I5!9Wb zSj4S7SKBOM)CWfdhAfD5m0wGMT<6sZG8j}3(DAUH-W4K@k^q+w;9D>xY>6nLJcpkF zLZKQZT;NMq^=%VX$KmN^*Ac5VI|qSa&;oaxg@f~*a8-2f8R< zsN1jzOD;2@%7$$l^6c9-KGg793FM|9I}(XOD8|u=y$lYG6`_@FdBBcQ)-xn{$UvCdvNh6M{QcJ=SCuH0B%vmtsO@+{-| z;Q9+%Gk)f_nR3a+xNa?p4Kc3;Qa&WkMnP2&z*|F%+?Zx;>jt10rgB(9Emfu0al(j7 zMiM2YIJ`Qd?NX#U_gf_>4YxUP$fSlh=UUjEQ$r|Ka&~M0*~EwC)%ngnX^qN$DX0pR z!ZT2hC`BPCJwME@Df#pvj3ZEIm9LaC)h36t)PHDA3g{ZJ03xUsADD935Vu{}N+%sN z6`g6^d_>L79Zn1B#?6aCy09IsUg&44QXGOqWJ#Hl+QOxe7?K|M_!{@a=p?+9BC5XT z=-5(sq4lc?s8xbfi@sL}fPBrN)jWz;6AxDIOHn7T9{B^p-{R1p#-r%`dE}1aMKh@A zML(K!H>ssATZqb?x6I8W*gP@LNXjr6bwyqu?b7#g%W}#n*OCtp$mMF84RJ*0=0}%x z8X|v0MX=6V^twGRIj^VHk8NsEY;Nl(F%HF1FDGH?E4?hfz)yKtCQDfa3!0;o_{Aqh z)b+HHekm!2V2hnYF6ZYoCa-tA*4*PfD4R4CJBmeGMvHyJ<3~fr++GP^3D;W}{kSmo^{CL#?{gs^qIOqrNt#ih8+SSG@gs3;$d6%>+V;-o#Mzq ziSiBj<|ao!_k8kTt5j608eo7B9N^l@v*z^)CNd(i*8CvX{6xiZH8wZI+C#2JL4DtT zh0m%l^pqHx%wzRRvetDGo{KVUa?~fPsE%SVL}l=tc=+JMR6%GGPGk^O|E3%nh3X+I zBs>8`p!Kk;jvzz4LtuNMKtOS{3y&eJjI^=W93vAs1w@H|GkTH{Zq8a58Acd}$cbF& zgf1Q=i+}h)+kq2c_JemS(U>S?fr&nqDuc_S@9&qnRjE0bbsQff?IQv`=+T1YM22U9 zaISDu;J|-jQ)3(^I^P+btOdYdUaogg$IUtmQKvNq>+@qD&lafXgZ-KT&qDc@;i4-S zSeT8AW|IL>^@>vXc3%y4(2DLK)L2mqh657#48SG~c-?OeB9i^ek}Nn_c3zLuDK&8@n0@ZOA#fQJ~dEC74oHzRUkVd8>7^=w;$-2K#KPI<0@D}0<=xht_q z6YGp?44TZ8>GIq66A_}Knj-W(%KzDu)!7Jmvp|M0$9bO({*6Nj8N!c_6yS6TL4n_H zek@Ia{9tX`OgjD=W{*U@%Rz&dt7-R2t{J(FD!x>*s@@udilEH{cNT;w3^M4 zR86M7){OBD7S|NJVv$xH8;=hi}R_TfBnw_J0m)mLGP=HVEFwvP1lq1^A`OE{le z7Sy+ERmu;imn*~BOYb-@*a?)as!bsad9}k=ZFMWzb0XzDWGKUJ8>+BqNRcqey}*9A_r?sKLqt`$Zlf1aM14$$@+(fpLY(uXoMoaLN5 z{qW<{tt-~3h2B^YP3_o>?L!aKC}3Q#8D^vK;Vb;_#OxFsHIabe%y!aNAKa-BjBAF=3Z^iXuTKMZ}+XdW*2l3U&^&L z*gGQ4l4P3SH1F!g^c6M``uKp8$89ik&V_7@N*T7(71>EjR=N~K%#A9MX>yI3miw>1 z8hDMq?DmNd#W_ZGAQ)*$29`~8;LU}RZi?)gK!?1Zv^GyU`Q7VsR^^qWu@N3Lmh`a$ zBI8TgdbNAaOl056W{3+k*eX6up^S6DNIiFsIucGD*T_qLR;gm-Q0r~m%=vEIo_Lac zn=gj3a%IPGM3(4t0aNzO|HE_aR-Iw?tZUo=^(-cb54*O|$~o_vpTbyPEBLs53LON5I$ zDZ2P5QMqNpWjIh`R6QQYGzYUcV{tk^;6~U8+tt*eraBeJf%8zUUa=QSxXix{oLKhV zFC0#m6CQXaP#Z{UjmDu_-!hT)Fj@Npp52zi)wX=c71u&)KX7iYE{3Tp#qzGJl4uB) zIK->TRIqgYy0wNGBRZU5R5r(};r0!1$t?y-c{EIZgS@W-XO6Bt)u#YUK(xPS=2Tu- z+A_B9T>aF!;f2PnRgvA!4kJ|S!?t5p(vKyj-17a zIzmM1!7*ipJCWR&2YPaw_QVy?ZDlunZ1(bWL2lHhsiFRAQEDuW#Cqj@ejz32eTLJ8 zGNgSe){RNVObNd?9W1iJ<~$u86>kUz^7*q5-IGGA!_izRYK^Dpi1NDWnQAoDB{K)L zdnc6dJ+{L*pMX>@>bS*WT)Wp?r(_)|&0Wh(xWPyD3{r;C0~qXoYevdYLm*e!%%=Rjr;H+)MCMV+&rFg>}!>%BU^aHJ6Q0YPaRRE zqBBb+p((zz-CX111NdD4p9zuj>u0wTN|5x2-@x)WUNop;q<-aFmI`L;yHdsn4q>5V!a;C>DX` zuZ`s(g2M*&RV5&oeYSB!!bhX^mM_@OH<+8r17B>#prijz>QH{wN8QH6D$Pf>!@X!S!>=ZVWlqz#+2RQLy)fB~aqdqSy@B zI5FBS!Wo+pCda|kEN^Mi-P*dxw-_*N2s-7)M3@=V+!_eOF)1OWgfPe)0vY*kOyRlY zYA@1o~}1`xcb%qANYfZI&U*sFm9h*8FuG8R{01FGfrXeGcB>y->}=ej`o z@L>VUC69Qw{*L(=oO2Xa019n>{Fb2;Ii~8DLaVNhDS90?yuYI0jTKm2TELkhxf7G3 zmic@5yy_C*AM<^;4QILibb}#3Ym=NCH;5k`I5ZueW6;yK6j~7Hwq_q3;nj!$KN2oj z;dN$o;_yddY`~+4;S?Y8wL=fiGlx5V!n(_hb-1o>?)1P9rTo+JI`^rxO#!OkrxHFxv)4F=wNG=!^aaT;p2Ir1Rs{{j?~^+>ME#C!=5%E=HROUg>u%M@#%A z2#ijd_Q-l2YI&7pq@mVf_|Z|yfPua-8cjzAGDkW~LH8&T;dI!_p2|odpgIbeH)zgj z$E2)xME%@Gc!~*5ju^KQ8J(79$SnAh8R>0u(?D` zLvy3(WlrrFDdSjGH4gvoK5|({b1vRigaCos55NO#;_%cXtW4Gof0FiEYd%LT-8Kd4T_{tg z4{pQ(V%+@R2c}mRGOgpiZhEs(lAXUT15yzMPra~q#ww`MFLUWw6=Q$H-ZM%#_^O-vxZXnS3Xhpf+xF*-p z58vF6;A~+~4eJ72_`tcgMzp4Toau7SQU&+Q?`Fat1mzRILlLoAP5?}Rp==6_>u2RXFeRA za*E=F&dYt^>Gzry9h8>m<|T;SdB*P*!i8*wz6(n@qxWizXw42luv9QT zY8;tM21{1pqa8GAPSbIm>KoxZ_Z9g-XS)jLk>&?)hhGYqIv@^>sc-3+qMfIlOPCOZ zJyJVm+r|=nAy8=S_AP@EvA8f=)9e{&E1;T0bBzEgK|I*)L+mM z7%WVdu>_ROH6F(iI&@u2Hq!fa!sU)o+xX=|I5F%Y*84egZV zkT-_llWpH^DEaDEE2Sfuvt=)UQXt7nW-d@+^6AUv5Id8_O6{<`bcXFe3C@9ZO?|Q2 zC?B^zc$6@#!1J_`tTh^O}HX)Dt>jbz$b(Z?)dhzrFZ$sEbrb+`Ye&%O}oo(MH z`c_%?Qpu)(RT=j9!GNqXd}MJMCFG6;L!4y@a3loqQM}7=^V&&^V*p?3Z5l%qM2vBB zVF++Nt!k~;6GIMYL6TE8}Layq<@!e#27=zixYpWwwJ2V)F^!T$Yx-{&G)+x!x zbV_7qr}&;40vPQjTWA>pxHTt4sM=yx8=b~n$!tIT2Eg897oE8fpydFlKmLG*MgC!O{q{{QEo%9LaV9>t z3mX?QH%&uB;8PkFB>{tbY0f;*aBp#4m~67+LfIJH4Ty(q*&wozy8O$SE_zi~DVY;y zV35cZiwvw4rf=BmjhRYjz+qUIpuSdEwte$y;pq%6l)EIING-{fPx zq7p?o9#GK!$7A2&ln1A3@Vx zS&TZ4Ph!sHuI+?%*8Ss9#@KZ*^t3itbW?63SsmT@SQ3bb%b0&n2%mtJhI>70 z0r=#^gy6!64AW-%`*EP&!3raRfw2<=P6%)V6_W;-swe;fat;9`+~_1H57kIa?i7w= zb9a+Ta-oz*3R|f4&f$P69UKf7IzEB}s~q=kDs&y`%Jh6uAcToS*lU3Pq=M-Jx)~av zJz7Bs@bjf@t0KS&w=Om&qUFdXd_+eiV0k$rn+oA0p>abfUBGRv_|V73nIkz`Njs3J zAwU@V2qT#R`i6qp&=jMyqUltNtyTfgX#sHFJdxEamEBnrWg$i=cPw!m29~*|rJrVZ zKVc$VlGS)*zrY(+C0Cd{@*Hki4Q7H*(vyfPULU)|H3cK2kE4d>{cta%~2yfi6#jhM|4Q`l_@n8nDCBp{PZ zreXKY7bux`%J*)+j>UX53W3!w2n0uJkqw@zr65OY6WYP<0k>frHA6WyfCOfP9Mh1U z5F?P5b8q6Mu(uoJBLooLXl#ZMb{l9rDw#<7oBJ?&GXP8&FfHF{^z5&k`@9Q*;ccJ* zJb)1m@V98q3x^0fr9Gj6v$**!bmR1K2q{ItXggeU(h3C7Hv<}h5CA5CcY1JgZJQ+z z=4rZ28^$X^YieAGN(VaLr2QS1&6sFZ!{1d!M7@VOo0If47T5cLovEu{U*r;ls+J7r8I}QNn}FeBb2Um8MC6uxI==HdpZo3 zDQnptd1mMWk$z9muYYza{zzz*h-t?~_8!33{3<%I;NZT-^nh@|?F){&~ z1Zy3h&Ge-a^9 z9b5H@hi!WOm-Bk4ojo!F#s_%x(N0qKGD=Q)mg`E@n(Bx&Ao?|iG6ZKsgdwC5$WKOa z9Am4#Vq+iz*aR3niXQ2;c?Nx_zfk~l>n+y2M134W=z!ZtLW+xc6gf-gvb!v~goR$TAG4L)P%D5^a2@2NYjEwDc9K97QFw<6 z0!;QeXax;7nCjJZrfNGp6+vITfvqo|_DxvAJ>n<|<(kY_052$WKR}gy762g-$}Kl2 zW(~z^?SjEW;t0%LqfAPTwvEDzqll8-KqqsUukCYXdGOLSE^e;_p7+)la7`GnK!A>$ zOPa`WePL!HE82TO%`uv5kFD!TJ8bJa05#%%_}9&_Xb}Qq5*Cz@(GB0@XCf;W%)b>&18n*Je!7k8SBUs9`FNQ0=j?wcw6$3ux z&BMSp5a}IG z{c3c;>phS?MJo%voezgufBz{oLJKf&LZ?>imx#X9sX7xr{IG?C8qZ3Y4FW#2%mcu7 zGf>LFx3wV|GHqbUFshcrb5kUgvtUI*fw(2Y2GE*6>Plwr9;#u^oIH09t_OG;m_a;| za!gbK(d#uA&^6UTjt8f>ltD27f9Nv{&Ynmn>o4ELYjy@Xb!;VFoL zmRRlo{VGy+&CwIAb8X5aTFY;rat%xjaQzYw5rpx}MRT&71ij|h?Iw@?^<5&|Y_4_= z4cNn@d~U2~)r1E)?w|F`W>Qr)W-h`Cr2Hh8TeKh4IfBkJBR$}iF6D=wSk)*#3?^8N zR}kTzMTf&-jL9!iiYTas1g@Uv!q6>r7ogLB-(P_@Ri#E#X%G)_b-`Vf$*#WiuBaN8 z+PZ{%s*Nxfu5I73JRCiWQdENO{I%BieGR{acKU#P9K^s zkfn{i;JY+lhh`Xta;)j77vSQe|%8_Sj&zaK8r78ig7a}#Vmv6qjit9b?$hF!Xiq7{%oYN zP_{1?z}A`$<&R(+%Y-EIxuZETjjE_@;c=py8{hQs{HcXrPbYEQi~n-eXJtN$*i!CY z0Q1yUS2@f)SKrxlM5tk%Q_i-Au0eIxzH#P)J4%7vaJX5&R!i5x@1 z67`jC;7)L6GybM~=mX$)InzkBoVRxNg~pRzv_(h5TJkk1%1}(4{)X_WF2;(X2#&$r zq9_34rh^p)*oHC~v?xXd5eq2Yec6r`eF*BIxEU(0*5djDxxT+54u(;54+--pDzDK; zU$VcSHHOB>UCSr>Cp0N0u86v7O@DMBcEoJqK7D2HcsPnEF7mYuN~EY%bQr>ro%P+r zB+EU5O+e?NHy#$e+uCLiR&mm?LzzSOty`NX>N;Wanc;dgh0q>iG&2bTJ^RG2S;J42 zh9sGcuyy0F8rFi3HhTP}hrB|JFcUyn5F^57G1oFE?wy0rF`rwP&M;z)B>23&{n!W( zS+Kbhx7~!UajUudK(r2i7eOq{JYIv-#Gss0clnnfCGwq=FuZ-ZHdAceT`= z%Bo;5pu7r~j<}{JE|bbQP}MoWmD&Tp!f!>@o;Wu|jWv`z6#;|`M&?5yQVM-V6hssK zAO@jg3j3DHe1-Y%09=O*Wa#-J-TL z&S#=3xZ8*L!fULYudo%~@Kb%ZiXHpwfIGdZ-eUt9t)x5W~&B321zf|f*Mv>7NSLT zj3~ump~pmjrj|i&^GeI_?bvqC$+^5Rn6j3EigLJ8MPEk1^+T4++!Qk}M&jJwf|olW z{1m50Vt-A95a?nuO*v5aH`$?U51WHg%i%sr8czsQ*$`CpE>KDbDnZm9%P>}vv6YBg zGG8~!-a<8PV>xvz%!0 z{>3A%RaX;5Wj-wwCWgCxI`a)93rOaDc&hjQabsCOUDd})dntR=U$aj6+Y9mogjXEK z`jHxM3*%njcR-1iYKNhX>vnL{_>NC^%BKRqlx2Wx*Y+4#T!$-D+Is^NcxSORx283o z_&z#r#L>rdgWzbcJq}G&4<4}tG0HS_NsY*j%sOtmlQ6R|j!9?6)c97ygKGF~aN9RE zL`>5gRR2bxvUpjTAr)18*g6xaf0ke^L8>O1O87>KMCb4TduDi21%&QL914;nS~#&E z-SYmpVg?R2ta5aiwr&l=ux<$q>P68ed^!~e++nYo7fO*KIsPilf9cVNObte;Yo2hn z*g4Wdm6Hyr;Oiy0(_?azvIp^le3lt+wPhJVEvPsT*5Yu*cx!njCFRI4jY@$YPB+c2DW%~6l_r|UeSo0 z4)|sthS%3j3}6nYLN#~5X(G2)3++8nyqY_{&!B_H%9b@TK{@{Tv=eQv1NNXj61twZ zxQt-I@lKsG(8>sSWff((|1w$K$)jUTt-OcOo)Q2H^Sy+KYy(La4+vuTc z&MfgvGw`7prFQTz99ZxhBcDEOhf#!Zxv!jTXWiVh5JK%(H!crFT86WQ?tKF}h_nYi${RpxDpX=A1s z7kk0N8^>1wpot-TVGPm*6pc7u_*RO)Xfe!Bqhst8Lm zF0!0yK8s7+sJ2;76Ba6}vY2uD;V08%W?!s26L*X=6DoS&?ZaRu`M{TapLA`FU(m|R zO;UV*m0qQIA=F46PO2Z)lPXVl8Ruhq=xzFl$2uJ)q_meb>_tgm3gtFYyIm`aN^hE@ z`{KolxCm7WFNFuwOK?re_Cp3m?$2#~cRq9F0}nXBtIA$;Qp44t@8)Ds96Ll~Tfp|V zBi!(GEVqX4u+CTX^qO`7jfY%o4S3+?ApTayhE)A^5f%k5?vGs9txIi zS^a?cW`I?e7&ysDSJzNWPEU(v+jEo39uR;Zj)E{!W1IU@i)Ci8EMO0Bg$mfhVBV?L zvS`I}<;xy}Q`;3WEq(2x3&akkuawe>tAksnH{P$I7O+@eGNRTy%WBXji}U6qDhR&c z6^A+8JJ_*O<5#aZbmMvDI<8@FyX4wp*V;W3wsdY~U94&Zf#Q`>BnQx-ibt$@(2HZfVGIqTb%dgGw1?X1hwRQyMIvm)ypQbSI{M)?}kG zQPjH-lc=OD29E1PxQ@uSed%q}!*Sg3kQ8B4%VB|CpHkcyuk2)Enj$HkA>j*oi+ zVYJb@_O45z7H^*FbONQ?#uDJIN$>OD(v+2(!_WH#0b&uk2B({#7q1B<4VcIvT`4=a z7(W53`cZT|r~YvWZ^6xD59iSi8Ln`tB;PYt>$w7EV~rZcV0*^wn#{-_oL2G{wvlz1 z0I-_d6VEZ&E%SW}IfA6!t?BUd=7!WJf|rO4lM&aN^Xf2LQb?+IhhfHsBICSTXrh!8 z=T&sE$8Adb*tmt6UWliyDpuv94^-5Vu(IAOz|vrHs~bQoLJUzYbpHsz1JA-I!8gJp zX>?hTLINcG(4Ipc?E3TC*XXt0w0Ti1sblxl&%ne@4D@mMFUGU`C=)=uU%v%ZjBmY2 z=mqRanEApj!oq_&qpxl4$RRo6w(5h**t)oHKX0{Kt&3XF*wTWcWx3gx#bM9;@LT#}-r16^ipZUMmJwfsuLXpaAM_=cf0Rs^MpgBC#8-ckxBI5IHy!mA)c zv@#r{$3ktTWo^l9EwNW_fX9f;di@qg83bEklzVWVQ-?1##se4_YUK=t(J=C1f^a{g zF@&{fJ5UaSJv+RjUt@s5oO}c~f-Kw|m{@faHmb?Z*F9PbwpO#M%+D5^Wz-fZ`{D`AsYJ+o!O zW{0E*Id)V-)Aac_ZY`q~(Vd$k>J?cqn-94p=^k=n7y()l_Q-9?@_MK|0Wh9(c*K;B0+60ykWrWrSeH42;&B+lPEIU@=0$+jQ5% z)?<5XrLlDOKrI7TxAy$HY)CDAq!&gsFoeuZE@YMsJm8*-BOMLldVtucb0*Zd$6( z?>_S`r7~4>XRcGZsw^9ARIP%49bWIN)P+v>tmRb6Da0*spf%td7dlM56?x!`E0a}bQf(0cjK*3wf}=9BJk(Il&C@SF8p_q2?;yfw3@fEdH`_RysY=as%=dF_ z4!T-`-SP*H-;N_4)x9kpsyWMD6V)qEx)H@RX4k3Riwv|nH+C$z5BKINiXIN5%|kk6 z8tC*soR1ENU3m>oJCKG_xLC$;(Jn90u$ENnD=Ite?OpY%waLhHcA78Zmvcx``4shKDUeCr;8P0y@xOZgH2lp={rr^vLa2*I04VMFgfMv$L2OEr%&I!({%f1 z==^Yf$ZD39WE|R}7@2`0|L$f4xOVN$n>S>Kl)!8ZokP9I@J)QviKBsQiOP~fsP4Q~ zd4njjmVl_%%{rl5c(onDhlm{mAAw>yKmnlarTT34l(>&}+ykpKCcf9U*C$jp^miGmQ>j0Gl`!ab3zze}ej$My`fxbpZ!{$mbFxJsU*S5EE zJuptx<8q4I%E}3r#mQXgCn<5x{xU2;w&`qCx=JR4Laxz+;agG~gXP`6xI1gk%@qPM zzcHK7niFMy7#`a^xR~2BS7@Dr%8mLw*LQDk?;aQj;cP~Rmw|?HO&{CGGYlU{|J1Q} z&b{TiXgjF0IJMVIYxjAC&$?q6gH`vl&irx|uA<<}eqYL3$B9~VNf%P%X$40TNLnc@ zeROcL&)|LF4A?$#Wl~N~V8{vPH|E;qG6Z*4t<^cAqm=OswHi3fii77`FO)%Rwg1Qf zmRmDDAW}DkaEvgv8up*wsrHPECWq0GJ1lw&)Sz(6wD%lt+0E9Xv-OsirLG>r_L4o<)a&m zZSS@&XCyd3&J#KNDVkGe(TBsU`bb8bVdp;1@R%E6q>pwDZb7X!3zgpGgNykRfQPl- zg#fp1%^$!uA!_^YK7RZjQx6)=APb^{a?lIE-Y&c)YBS29Vj+49bL-xnEK{vwPcQ*- zIDImH0IuD*!l}<0y@$!~?9kDS;+@sNG;#IvF6wfpi&ipzHTu3hj$sVL3Xml%qxTn!05wv(2dn_0T8bmkqs|3Ue~aftB0Y4vY^fLDyOMKl)R!! z=1J@8mZu%F#)rjZ@&>pA8|=lL$F(T4SHavGR|nMyIRf~n#tutcBOOiH9%ml6#2l}> zk!U!tnz@07OPgyS2s>Mjnm>rA1lm?^85y=!zrVIpl;h%1^AYS2con8jMbFYBuPVM< zc%_Q$sXw4Ks*)|O^}WUaW>`$iImw-;WW6ya#E#*g9A*nRIQk>~UPFE^&W6SO#m~%x zv#o2kl0CQ((^rk<^r7hxi&t{`IM)io`0@I?i^2Qs>Z=Z&=F#oOWpSkgZ|q`!>-`<- zqD-)xvc2tR`oboW9JO`WX3#8UZPJ_zSEJT_I*f>o193)QV-(R*7BUpOX`LbD9HFsvRh!Q_*h)%!X}QUGG0Wt8>?BW4x5479KI>p zp0sSqG50Bc>!aDy9M_yzV$H764CCknk)}0^$k6%%9g?}g+pe(kHFkDI4;>aQ$3qQc zpcL@_@4v}l*$UoUz5|a2$!Co227&-m%Ped>+So`BD*9ZN6YJ*e2NOkn;xJvhCvYmS z(J3~k3o9+D67N463oTHvt+3fyKX)pd4&qv?bsrr1K5l`(+89pS4AZX+!+J`6`-6f6+M&(@#o)M9 z=+pYb4DiM4q#zf@jMj}rLUE^nIaq5;qvr>#X2R|-5W&#GRUn{0l;LQgLOlJ=4 zgNUEe^ZtEnRpmB?G(&Dr(NreZHeK^sPL{kbg>@8HtMN{nF#e^PDlPXs21Gr!w@?a; z_MD|i`o#2dHILXa^m^zy4-{9A^J;lcju7NYRE;Yct1KQdUv=Tk&!ow}ja95(<6_=P z-}A@|XSqlZKD+T%uvR^-<5==*s74Qi7t1RiE)SPcMFTt4hu3yYYj-x#`Qz6Pyg9g0 zxP)o4Jad^V1_W$2m59L)jIkb;_F{%cBVGe)d&|p-{{^;$-A7N=IDRR|8)?Zg!(7ps z??jsyAJw6KTpu$Z5cc_uXJ0OwKT_a(wyafg+V+Dn%wqtBD%)DDYk;tpER6{;O=p-A z-69!=59O}j6=x-3crbxrCDNRC4eF@7;8NrcjZXb7W87ZeyFj80k4m9)$FBACpa&JV z4}C)#*h`!#nsIV2Jg-zV!R0^Dpse1Wa2|a!WQd z+S1)=O$AhnN&U{2lqm#TcInXO91}WqePX&t>1DzR5+s|35uBEK_%Ov>gh+HK8@he- zmTC|~`@Ktr&TWfp*ez*n4JxvtOiAZ$Zd+xSkx@!ac)Qyk%P1w%$1+6&=p8s=_*(cE zMVSMwx((lUY?-NGcU>*XIR``+*=3ys(P!#&WgooN;2M%`%aLHCs!goM;vif*4laVf zV*#j-T%SeHd~!;UuIPLxFtuwFospdHj+R7${7Zmrwb(L03((!-qazKZjhe=Z)d{b#Vic>b4|EMAA50+ z+d>eu^x%0~G^0aXN47ZI_U_rUM}vS{)?A%*S^j{(|A@6XVY&0t!jf$e zZbya;x+_XJUNF;T?tav9J2%#J^bef)!fH;1y?_r$ z2DP)Uv5I>T>2dfgDENsW1eZN^#WriBVZWA@n$IMWF*z{#Pg5Ak;7JVl(8+FDHlt?jSHA76fjvGDl2OPH*c}Hzr2EW*Cb$FI~Rt&1VY&G zvV}zM)}V#DU?enq25I^QWaUbptikhBT8~7L-x}bG!E8&QI0_@$hNvT))aGb-3tN#2 z);`WV>-GwJ6~jp5YRWY@1N{0HFRC9gOv$7fy2OqakeHh5K_XNg9^X|eZI}Bo+Jxay zK;#^Z(9pI*jxGUY<65XrF!?2Rl4_a)o46*jhEmCXhDl^W=b1n6iCe_VEmQ?w1OikU zledjm4}DQhfF67Lsb1y|t^up^IB1n(cczmHJzjlfooJ=(;m=*X%}vDLU4G~7|St-Z%x?So4ed7U@2dzQg}w3+ee$m@k;y} zCug4?_@uIXw`-Zo9aSEfuNQX?@xToeRc{S+5q6(qq?B3;6pq-qH@wK5JBMXkMKTi1 zHnKFQ9o%&JZi;Rh-~Niz2R6f2VSX;06A~e&YHUY=ICw_%;*VWgwh<8>48!PCI#m;7 z)m#%&9M+#NA*8TZch47*&2l?;Q)#obIRW9kp}p5v1K74@3>j+QfGc4-(TYGcS7n7~ zJ+wJ`Nav_<^8k2(ajqS%FieqYHx(whS-H zs3mO}2A5%bYdFv0D}^W)(5#kawRPYi>fHpqt)r$kX)MvVw3KzwBCKr(7&J+dZ4T=J zZ_OvbpF!rFD5f^8ZmD1@A~Iqa=fjN~R1M0|kXw?;YP@(<(v?08*-+a{eSn0;CB<6p z(dYsP^nq*Y@Y6O6Ly9?Pdt{aA*I3+@)K{Fhx`>nv&{?lv>38i!sl?&SfRw&^$rzy% zO2e5%h*|#o5mrOhD=)tE2ZNOmj~ZsdI^X{7$c7kb;4n&ng$e6rn@5HKf$DK76Vq+j zPGJ4lCImA{f{1sZ>projLe#jiv%?s+(*`AT09dxjmKfl%`pqi>L=4^=NX)tOa;cWCZKx-I;3z zEx=r)*bHGL9`fQLb@w#oxHpft6-}}jfDW{X5PKDf-ivXMBJ>3gLVphFNDYbqTsHC> z${e6q4*=rF^+_FZ1l$)P&JPfZK;{k<(*eN0-^Jd=p|*6Hm4jmz9lPcFqF!I#9^~w+ zqzhb><4a9{0OpW*AdP5GUDhk!mN)veU(@>J4500-I;#nr@O*5koy*9Y!<>w^kEHSt z9qZ2?$@tM`jI0bIhz)f(Z`Pp=67+8LgX_U9U08G2oMAl^%^9kS&U(jV<4Caq?kp@`OYdLWE&6fd?!xz6KR$_G2?Ze|^l}Vd?|Z zci;;VMZRYmM#9a)cw^+cq5#L_2n{3Zpyf|NUBj2mMP*q%n@6!~sWz8E)(|~T_XGGS z+YhHUI}JHwai`aMxgA(Q3xR0{lH1B5ZWskG04VtL2e_@dRxb zG|q5HSjs@st%63;Ii}ZKxg7BQ<`8%AbZ*{!Ye%p-zrjXbeOo!_aFxh&G;E#_g zgeg4wC|rfvY5+IfNq|!o#ItI2#89--A6l9~(YX`zRh)rl30TG|9WP_&0H#D_#*^n) z4-h6ZNH_-{f`BjbV1Q_K)u}Bzk zZgcb)y18KqUR}Dhb-5b(Bh%Os3hqiYdVBK}9e7Q$oTou7Y$NqrvMt%Gv8J`NhT{Z} zk3aeSyyu^Pgd6>cFt#(OuXcTe<_F#O9)Es-AnAtrr!6S9qZ0h!D>bDXcc_W&Iq_~M5|`l&4tvR?C)f8dvWOt) z_OqT(&-3E>2ws-JmOs~@Uu*6J*}>E`R7b;T0H?S2?kC?LX?ruewTNa8>kAVvZU#9# z`TcfAh1M>eY`Ibu2+D#v^xEh8(;vLYFTdn(4iqU7@&wBHxtg*ZTUqnVAR8EjTVxkb zsn`Wdf!#o`zOF*8TP--$5~o$?*wPZ$a8nP3o*dAoOP2I%FIpUDGwPxEpi7<(^jfKZ zD_Gzg!v#ax$@0z6l+5~Ym|*1eg*?kgeEaI&xw;OyDYr{I=peNxvJYUIs>2b1AAUve z8uhRqrdxy2W?w`8T;yOuJ8~>&-it^Yz|(CqXSd<|Jr9trJBMOQe3)t%oUqthuDDZ* zW%JCVVF6WoYy^Z$FCEUY3w~z={j`j7 z;MObi&YJU%EB-^)IIvhu8*@)Iwk@XX0iGNhMr+2F7S6~O7hZumn>jQ`)k0HT2G8Zf7nD+L#~&{|V`7Uw_Au!34v zvsXDyRwo4~$Z`+2#H3e^Vj?Za6yOPHpT~Iq|B|JA^`D(139}6NuCN6D87$A>E>JbK zXO80-#IeJUqhO5SL;2c#8yEla~xlhWW0zfgwIiZhz#DfD+PE(&&mIX;EXquk zK#4CNwalClQ43-8SkT3=QuEG5Rr7roT3)`p`3^zY{N-FIG?rB$y4JMj>S?(#t)uZg z`TcrNzZPsQ*PoLP7$x%B8R>rS{=Z)mfFPkJBUdu$pook!-t)P?f5B{Gs9fr*LXZf~ z_x}I=gMB+#S8ZqR(sExTdX248H(Jl_rBxbL@=dUw+KI3>H^LA)M_T}y?A@GMaFT}d zhE~z|R{!*eJ^B4boV(dwJ)z)W>arN~t@Ov9gbJR5|FCn7GglYi@_zKW`x48S5T;lR zOuiga*cpAk<*y(6+?|&WI<8m_-J|ZZgC(&PG#p29U_cYmK5B4Lr^Td%nbhoZ6#${i z^Tx0g6gIxgR)h@Zrz0;qQXZ5rR-@?+iVf4r2UP{~a5K1$&)#dNs^V33-r`N-G2l+o^sd~ZfM zn_DekAZ6^`TQZu&N!;IbxtRW|Smt{@ZcUlOqcea0 z!1+6o$1(h#vWIn2m!ZsffT2cVf+dC>`?P}Vj0)0pDmm#~WLuO4BZk>lzeAkcA}wXv z1b{vUVx=Zp0#QUc4P)$}n&$svJt^A}2pRTb1V_tJoSEw03D#@d%XfDksv4!x^8MP4 zJz0%I`!N9)1AmW8k`92^e9{3%wo=6w0IwIfV|06HOA%{{9(JR9y*NsDDzJ*2T;((N z%7t*L$Je5WxYURVVO<>Oz<5i)o%jQd3}v+xwgO)<=z`N*P**ORl0EPPo;Dg!GYXb6 z&I>NB7p2^0d^B%aQRrio_vHN?Oq2J)gm96?#A8(Rvw;(!wPd&Zy!pUs<}c~2!9Mr* zU$*9D2!lH7b?iIA)6zYeJeZ| z(TN32r&{cwDSFlo+k3Vix@{;p9z2cFa*=%nIWYaC#2N1&-jf%7gaZ7i z5V<)!C*+)UDDpVqg(8CbtFg^+Y0n;Jh-YwS&QQGtoKpgY#s{KpcK7o0TAh|3Mak&! z0&$u9!VmDA21OC7t5j%7UA;9YB3RHJMqXeBI;f_Wa%j1cW<{dqEQZBYvNb(VNo1`S z@ifyy3np(SmC4I1m``+X_9sk8qBEDc0(>p~z0#MaLhEMXl~qEvTCfe=ywqOatiu3c zFm)W~kV0>XE3qd=Qama+M10XU;$nQTfY9wzDYOA3<-YiIZOMSYZL|fm=>L9fu2{`m z!157OHer1V>wYoippOBYWNgMR5Ut0PKqdm;QYD&ekB!tTZocmB+$61AYK}g(0dDXK zZ&WP?SY>%4adUe+%zm>k{Dp7cN=uJfZ`^rnpN07MIoNkeEpZ(e={%`QTk5bTS>%vw z3DE7<+b*!s%nYL_a-KzddEuC>b%ITXN4BGcS8?_oP_2^f@L@Rzo#^;k2}(_NAID+w zUd?r|-m(xy3o5(OQSJHGBVTn?buwglJNKbO=8;FDZ*^#hvl3Ps zbKO#V6|~%y!}93EaNm^X;#H};rzvWM)v)+MZOy?(A*7xB;1swmf+Jh}whkQCE#xOb zN8fjc3zuMu;?~?&`{i*>Icf|`Y?(&O1)V>B)M*?!nist>7!j&ccQuAc3@met;y%(3 zU@<5SpSfAgxzbZ*k8#_#ERMI% ze7`<7U$sSaWb0NFA}kIv&bRVsy~!{R>QH^Q@KC6G4j$C1Z>e9fxs={=B2&-r_UvFv z!=3hXGx5Ys?7ZUTwV>2AtM=l&3E5>d^Vo#a0dg9S*AY?G!wNy)4(W&^yr6WMIR)m#*$5qdfpWK)}C@a$ZIO@-&)1qOm5&I?14JMW}#MF>#T` zq}k9rVKSKEUU3Cv{^rQs83WGQo1&N5?i}g|C?9KojcJRovJBEc;B)__` z^W2@k$#;v}{?ZcG`5x2FM(WXG^Yk=a`ySvC2?}jV=q4P+a z{rd3Qa)_O3HM*=r#sFm1wAqcr06;Q9*##}4|fC+;efM@U7$Sfax zKSJmhG9p~_f;o#*_w;}e0S@T$o@Y8EA@t>98sbLpMb59;tAVM`w_mKha8txb!A(>S zar+!*_udVFMo`26Wd2n#(2{WRaU867F{T*S8t1H1FA5DHPmASgU^-N3OTkcaya$Hh zh1M{eY{w$pf?T0Rmw^+tsJd7wh7`H=rghEPO)blxK=m9(hT8TLkPDBDK7rKWnzw!K z^M)GP8X8(udDo6zMtD6iU)Ez)N9?@*0=QjArsUA*&i&o=29w#KKFZeSj$BJ z+_P_(sFq`Js&0y26V8K%=jpgcR;L`n84CQ?d^C+Hmy>9WA}GvnPLe3byvO|#>(UCx zJ9om@L^PM{Hr|u3p5zPg!$58Hod6;Ry$9Qq(jeNy@*Svoo)n*J^PY3hv{uno_GO)6 z^P&h}Xe>7r%9sy>=!l?Iq6Gc|o+&5ehr-An0p6|(M3i}4z99kzEy>I3mRl6?!Xpxi zB)AL#mAzQXV|9375b_VV0$4KOc*$rnih|iV# z;JYP^dDn@q?z%Q0O1Q$hWn^g>!^1WxCKd&hS{_jSrly0>PGb+B&`?G8Ysy>?g zr0J*A1vH9jmaY!#oIhwrcGfb?E~ZS@i7DL7<*ao-*BxGEIQ6Iv|4Pw`9gFxvIIm6*-Xzh@3|Qh zh7V6lIs#pqo!W(*{gGHW%^Y;-mSHN3G|RWZA3)Xb+M{kQbRoDBriHHPkgvJ#*yRwQGfaxiqi0 z=Txl9D>|)9O*j(je8ITodYfG;JvptNJXE|y&fi0khHIu6SyJAFrLpq)sCBeAGB0E$ z+P5d2C77>aHuRqq)#%}eDH?v#dpqN$=E?r8orS5oxzc)=_Vu~_B1A5TI!87;clJ74 z-{Tj0^6R1fY&Ua}EXwXtO)V$F!DgW63$2Y9@?o!DbH4Tp1Eh2+dhD?%K=cl`2{Km* zs(~9>wKez0-ovjR9)+`Mw>mPuK%=7d9(m4)YM9S7&>283%&ZxE^2_M4{Rdy8FVRJY z(`q#*pTrMO+E>gCn~Tt=%8Qd-8s>_kM=ID_Fx+F}uh*Esa^L?l)ocU=tnY>&TgO-*8MaheY zlsdof>YLr<-sd-6*QnIy-F2kkKBzR#9T3=5uN z3=a?Y=>sEby;`cvKJfKJB5d#S`XlRza=~to`kn&=e|$z_IG!Pyn2fp z15l;)3l=^;#Fyf5X%snzA^Eq(u#Q6rsqs9?KX*qo<`&nU2rSk)ePa4b^D!7^M(jg` zuN)CcV>;@j^F&(mEp+raINuwM&k8z#8Lv3Zb%sb7UX1?nihe0o+h1Ds(PhQ#=1p*3 zv-N8B)UjsAj?HD0{c1aV84ah;ueYkQS~Z>uk~p?x@GK(?iJ>Ea8>03ee*}(|o zrYy&5J?}m5SFyAa2j0b7y`oVl9((eadyg%^Bx3rZWcy&<7KzXG%P*tnmP1RmtaJHH z01(OtC734L2xMU{eh&p&;6@}6m+MUmzz zC$8akEjGLdv&P8lS&HW-G< zh(AwM1d*8TPz<>Dw>(YOxS3DV8+Q7{G(Ng=dSm)+Om8(G*jNir&U+0r2Q}hK)CdbJ+$j?LU{k*>z=IphuzWOCy~}qoWi2lj*hi!{dEU*4GDP92P+yKvw%r&UV=ZTclJ|~l zez&Wx-`Vk>zyP!TNX*@cr|dl|v=Bv>Tdj*Zk@T%x!4HBIa7OcwbF`ZKoFnFF@?og5 z;q>D)GhuqNZ!j^PBW&FP0k@~)XAnGb<{DQf=vlqc9c=lA7s%9J*{!wbG{^pT3=9RF z7js%V!K*oRso`D{#ebw05d-4`TQ#m6DeN(l9 zzQ)HsnJ-5u*Yv{2aNdMdFerL*zpP<5Bcum7E1G` z*UYtCo9WxjfpI3DSMANGo#&7K$}Q;`63Z4ALfPpA(7{rn5%jZ#%UebmRp_WWAqzJz z3*xkO@ftQNI?d!q6su|mj_q(nL?l5@!X%s}6FxTIHRpRiBg&fz4pH-yeHQ=E|h8%ezbTvs7{cT0EP5at92{Br+&fS7ki0pOiHf%)?J}%T~k#F*ReWjxE6aaa>sCPZXHa^P!_s4 zcqU+rC0iDRzQ6SaX0D~0(~p0_or~`D&rgxY3-wguJ?c)DzSAo2O6xN`efH5ueSdBa zAJ!<|=U`2ae6#QbffYoSv8&BqYvrqBG{6%+glv*78s$ zIudmoE%CYT7b~b9dd|Vd)l-D1;EhdZ?sKwj7lq==ZE2Z1DP>lm_pEHT_`@*}QC)AK z3**N7PP1Xc!}P;FOdNaCo-?`<=yA-h`s`yF+mC7QiN(}48C2CV55_mJ`<^7u$jp9R zsZX;p+wifJ>HFW|d!X3EGe=_(U=wAcPGZ-JOYhL{UuhSW{yixMD#Io!7KL!$c-FR- zQmcnZN~|w7e^9si%DHQdXLo+bYex<}b8=`|`qE!l`f%}M3zqD?8f|lq;v$*NleuM( zV0vkHWLJy9(%exk+qeDVm~lR#amLQQZ=0UO@~!hWE?smljoi_EGM9I43q+xtDwt}e zi%J*YhpFL+2q~o7=^9p1Cz>a25yj%cGL>X?d*$H6p)D!PZSOh0i9%sp4#F12JHOS- zzu3w-M~^>#^!V`Rmh~vqX6sVH6%Iuyt`-RV-1hPah=jbMzClEsutljMqhpj6TeBd#Qn=e2A74TfN9Rr1FH+Mg@YRjC^TNj}9!z%}xhe04HFZ6*taDl(9=?z?~ zLx|zfSmuk?+_iiz2&<9~WN}G{NlPJjQ^Nq9MY~#n%gA@@9}z7)OEHI{h%bH(4(7+!N85OY|jfvrx21lUcDM1P~8lC4He3JduYqYZkTUG{@ei} zkDKP*rWQVt;N;GeU-SF1tOitb!kYK{`p$5k{Nuz9V46N#4wjYKEx()Z;ys?zo=^cM z)cwcdZGY1HhxfQYOu^o<8PmSYCFjhXQ9S9r0Hua>u<0}1*1d+O2CpvSW;3+qN?_ldXv$-;&l+NGZf1 zLDbxll@4LHx8VqiwF*OoyH+ua!{sVW2eyZ#2C6^oO0Ofp!^KQ2AEzznm8;+Xg9c!jn7= zcyL1iHOmd{VAX1cq#CcT1zZ=6oQosD*``MB+pgIgZn0SK2;%AbG%l_k)WjlS9hc4Q za~Nbk=R_o>NOQNU?C5b=(L(xff8A;(#!duPv08H`oe45GT~-n3pY=l6p3MoPs)A~^ z8h|k@KtYuVe%;ve>EGrZt4WpfYQ5)zv`fQr&b~FVxOXEA0+qEtd`&rTfITlrhC2Q* zkDl!`CgOM7D{z0Z9Rj;0=kR33@~NRMfAp<>Lmry&9$mG;62w?ZCac}>1BaVyZqU}& zx(kboaMU5W7`g=~WTSEVzZq^>k$&M&?&=%Wl}S!PhefhoLC8Pj2IR(XX`;QQZ-ka@ zdF}n5*Re_owm+_7rK7JgU)jYMO8WvMwjS~F#}8p#Izlgl6^`0rczEt9eA*>ReWAS2 zwQ#_De#pl__^*E9?DK~RKEfB#DOQl+>FC#qK4|EJ?xg`v>tl=B$y_V)PAV&^JH6p> zYR;9v zqD03oHSRQV3+2w+oUzMyk2b#DDaRao*6{5;hn^fcv?T6i1@|ub|!d~0e*BcMYMGX;#+3t#th5%iK%~F>RTJX53+X$az@Pg=G{{{j9 zsd%(~?|7xlp37fP&{T_Y`VGo?da7G1b{W=go9~r|!^F&}{+aH|!cgNGK ze`=O(2R?oq1I!=|KI>}X5cRz!*SwUuH#wC-{rCdB-kaL10RYN5pcGekRW#3guoU;Q z-MxJ8Z`fSk2rKc8?$n9QaGQ*XHiTI0%nHa=92%}c$SU?qaG1Y1A>AHZ=(`jSrab`u zs~jorwgP>ge&D3D2}CipT6I8Q(+qlwfZ+L;T&Os2*^8xFZsvy%4Z;`e>vraBz4Da@ zn^XEAd!*CplwI350iMTTBu{8~|ILss9ZZ}$jWP}#k{uLI=L;|DX=QPbBRtC;I8fn* zhBe(cHQWYlM!HNJnuUCC6osbO8#Z_*=<$y@7Wba82?I&Do^-#sxLvxMu47wpzQucf zjl==i&CoJTRq@W~^Q3G2Ae)S#1c@%2*0PRvKKIu+fwYNz=Nnbv1Dg*=<@99YJbn4=!14jOb?b>FvdD}%!N{9=XzlB9dtEDq32_4Pi-sZ z+h)S@xC5QTE>^DFApr5s5h4UNTVWX zj0=6v+U61l7AG7mggUs8jkPHG&O?m>&ew-y5a>`PTQ2(9f;`H7ttb@RSrAF%(BVVd zj|s=v%RuqA1ZIswVd=J^AL`sKXP0p*VM^1bj-cum#g@LPqI@-L^j6w@*~LQPwD)cw#TC0Gv%1 zecRi69>8;jrs51+a%(K-t`RC=CCgLI9*=$CY$utZGf99Kg_s%;KxG;qthU{k2HD=m z6k)MgXl>19a9EWg4@#s;=D~>HV%Y%n*PnmWjbXK_BUGKKzf-MzBf}~K=dqnMUXIT0 ziB60_MLUL}lF@3T-Z@0=bfyb#a6K$xa}YXr9(E>)bKuQT;y~kE+mfpY0M84GBM%;$ zQkpAHYKC%<`2gDi3xM3fbl0VF-bClhF*+-|+pY{uh`lv;c^>23%eucT}7$P!jU zh!jwX?iIETXEF!VoAl)iizmolYia#ufa+qnGhS~fXb4%4`%ucXpUkZ31VjGT zKjulyJ5y24d-D62S&mJj$Fx+0DlbZ$(K@4(LG~(<4+nWsaBw~aOR8q0(7GJF)YC=Q;da?IO zvo*js=3$h%EsU0XhO_2uo*+1s7zgmrk%xiLTr8}p9ePTK$X}uTXN8zqZA$F;h~wbRwf^~$Cs_(FvU?|M-s`+L?OkK{Fpv7 z@83*U0+sGlVErPl*D_Yyxx_{s*vRRfTU32ycU`oP% z9ay+;uIf;P5SQ&G22h=Uon!yyTJG332<0~E+AtlSP2#SI-oU&5W@9GDu7*iOPa+-; zT;tj-x5LrXAG;wbs*1UFz<+-Myg9A{jeMudO92T@LJytf7!DNQt>R1?C~h%)7!D8WKFZ;xR$(wLb9_$7XE5>V!q7LzS`=QxdQveO7DnkpdpULri(FUVju zaHXq-IVKzS&D+-&F_%WTqL6ApH{N>Qas_x`XQAp;2zR zcF~a}uU>Pk@9w_$JmeSJUtSr$A1$}Gg)yJcM13v_jI5_D_??dL3ON`pE02_I(V>wD zZMX1po13ux)_mjvQlj-@tEmN%T_0P+%zxLDFv3w1ZR66t>I`+Yz>qQ#W}H6d6J|}` zVv!LQGy33l_eGH+m#uMP4}`V@HBu{$1ZFV|`(iJh@ePO~#TEg(&%Mlz++;ULA*2#< zglTg1!6WV|vNH%n89_vJQ`sVN-d5e(W3;ts4omBcCJXbcb3=DkQ?vGnW4EL{jALCK zfoTlxypOkB+eX!B4`YGGX@`Fc{0|7YgXq(PdB1I+pKPri{)^Xds#*_0DQ0tFOLPKw zLd3^rE1dI~TeqMu6vr08^cNl!L#?NUFD`rl33v{O z^-Q29KTD=OLGxXJ0$}uT(tdf&Iwo*pS9!^;GqRdjCquj~hpL>F@XD#Pg>MDi5Kng& zT^0wf$=pvY?!!t2(D=ZGk~QJH1g?D9El4;uESc8S zVJUK_mv^?e7gbDaA_C9mUcRoZfu&?sM4NS+4MBjtvS(%m7|G;Zrs*z+%jls&eYJW3qvy^}K1BPuKmFJjhL`?fagp+kMl0!GX`0xdJ8OSa> z2&(k}qe1{tL?_B3j*nd>+yb;dCOLiClX#0g zGWE*PS6J8%jRCI`l`Y07+l{3r%x&5To+Q>92# zoyhWQqJXxqWqaB5iE0J1yH)5iZ?}~q>H%wJ{gpgjUA&|eS2IA{Dm}mSK(DgwIJi(B z)+=jxWO^xB^22V_oJbdhi;a%;2*1!q)2aHBE}&ygnO}Z`5v&>(Hb(_>KLX280#Q%P zuUh^@h6S=!FHYdM3rnF0mpgcKvPBPTc)%@;%N)?|x0 z7B=a?N^HSnb*}||@{>j+@5&K+3n1%Fc&d5^SqFjj;Cm(WEQ~n7#UJl@a<%brG{&wj z1y^}8J_FZ0uQWnL3=GdK1DqKo$CWO($d(}Zt-G_Ej}80kE)TtIYN{hKhM^AezzQij z@T>_X&b{V<5~Fe2W5HsvKzgZl%q~{x!;Gi-&<&*^TU>lxmTi9r*SVGfmYmyNytdoS zTCf+JW&rNk96+mNKrjs+!CjSBs>@m}OS6@|xA=`WxypL(S}9UuZHPYnYsU;hJI_3K zD8)X-J;pCo;k?0yKFrSieR%{-Z|nlM~EyNP+X$fiXz9(Q#4UCFccqlLSv?5 zhceZY4jr*fGFJt`Js{nNYxS?J|DaMF)^w$G_K%z>%xhXS#6YRE3xE%KQeX6nZQ?7^ zL-E!yYj}KoMcd3q3kxK#@X6>50Ky8!qfC|2MCUDD^tryjMq!*BLFmJ-MdBwKJI)p| z2yw6m8(7y0g`dS7oH^Ip1^;n-EgGOpLY}1ysfXs z7r19M*7WU+0?i9Z6`xABx`v8Qw1%94=mfycWB~M`eTH*JUbASetk6^x1$`iyHPMrG zF=QuJ2?}Ck#$sj9SjpyTYwi=QS^KqWA#n|NVpiiDE0=j1*AsQV+tg)N*4 zC)(29Yv=P>8W(NpHnVWy{$OogJbwRqz?h8JO0>JV{1+Q{w6T0In)Y8ARdRCS)H8pVKR z4+b!70*HI)m2sKJnWYlu>j1j}>Uz)v>A~(UGfPb)o(F;rozQ3G%@qnV3=v1s*YGMQ z#He}D8ZgxXy&1tSLLlNq0P_5MBNz6=D|4_X5Y%I+AYVRnCYbcWQ!Ne6jrxIII5*KY zm04UhVB1CxS*%O@Rvk$ASd@V`h$DOoA=Gk_e}Zo=7hyO~(RM^}E}XJfSzyXWxMbQ( zkle#)u+QPTSsf8;R7_d?n)`}H*k7Q9FM6w}7r7nzL)_0meIp0>9t?0HGRZJHL#$y5 zz_AfIC%%Y1kqxBD!{yKw#Y)lN?^ms5328?q@{!$`wGSP+H~ksrpGH3;HB#%KdT4OGQ=$~X@}2q{Nla4Dh~ zLU1Q@%Y?WAjvqj{O$)rs7NRNE-QG?w`QZV#eM>eqQ<#cTPg^&a^aB98n7vOk3t20$ z)#^?Jxb$TRP^T+gr-}9?;01QE)%eCOzc_3gn8Y0HVK`wwNm{l)2mZATA*@hR5=*uN zIA2-Vp31hvP{M#O1bp3=2o|oK>7laHwm5#1xyZ}f185=eZe9TEd7iV?gizeu$ZB+O z0PxX~_~f_c#5uSxG<@$Q&qrnGY2*T10ygKiN(Tgt?d?HVdOh@R^q>sf0Ak~YuvocZ z7=+YFgd%5vHR2dX;YhMYbIi)6@z@PC97(8A2eEB45B>;4Q#WU*yQ`rR*nB&`Uh<&C zrc4)$Dbs;*je3t5p;9r>h^kv#N;t&1bf(-fiLMea4!FhDS*Cme{t5&yQaj(sun-#B034ppW=#-GRvtf_Xt46$WJ}8Y!LrZPInT;Zz zI^@jh3x}R@tLii8auRXA@HI$g7-kwgnln!dKe}z;8nZ7f)n3%4IlBUAN;o1%gK%?A zPFNKN8*zwEmTa4BT-RLLryIbR8r$Y2vL!uza0C?*oZ~PVOj$rVKw-J}sGvHmUMKm8 zbF0|CrE+0Oe!L(0MoBGtfh}Iv{OA~ZT}Cw6+Lb5O^@FyGJwwkRXBiw&+x4QPFJqsJ z9|Q#RjjT)~T1eV`ID;=wBDe~;2BBTf%<7Htp8QB-Lr!d0s4KuIYvsRQ3v|_|@APpC zAdIF$WrV@iuuFi$K@G)$0}mOL^m>@+apf6ZYpOO1&*iy5X6$G5#hfQ;RONEQzsxs} zXaS_W_Y4HoOw|V%nJ;zwzB3-tNZ9^|_NjZc7=sNhdCK?}YY>K*5nchhZwrDcm6O{Dyonv2h|GJ=aqly&R8vH4YCqb#ca zj_~40ev0iL4!_GNmTJK{f`{)t!sW>;;{fw0?jpL#R2qfMwiG~| zlnzj3gyUt;8>&l5O(bViBJ5W@YdYg>!R=$Ewl8hlca3}oD;i4?TTRh8xs03cwVdKO zKo^i%oZVI+bW5Id4;lmqP=O`=s|QA$T>inV8#{;6 zfmQZ&Lp4<{ZWnd;2@9ssTkIot+@_W8i%pTfvL7DVm<($Q^aSjI zA`rqX11uh~s+=SmoY-T0riz7%Ub81_wWhhPmSglC3&K|vuUTo?UOn3@*<5sfu%FX1 zptf(W=NOsnaIV6@r&IWzNw*h21N$3!LJ?oxvGT@T;A^a<-y6VrtN%S zKW|ncd>0O!sjR|YS?!yFSW4-Te?)MNiOjZPS$vPajOhmDGI6u7Id}%K8F;=ktErC9 zNMGhjf%Y&o;i(bB?28oClX)NvrMt^SnX(QmV|(bi=pFMtbslR{*}4_a{b4k-p5Hxdve3OxuiPly&U+6evFzBDH%i_AC8iIRR{ zmSvlwTFmWuP&cmEhil}=gNY(yLyRQ=FiHnXnf5);_=q*WF4=4>o}6uq!Lm-;R1Ng5 z(XH9a1;%r1EPakn0}tom$(cZdscURFqm&U3Xc7@!HiMOJnF<$mN#KpG*4aL+IVd5P*1sB2uqJ7r*&3mjzH*X{Oj?Z_}y1XR|}4o09U7TGS>purYxq5@f0CFJ7j?v$_c~u4y;@|s@fCS$oDl)C0r|j zA9ByTM12UvWzL#m-(Oc(;xj9Wy(DSx`X`*?Rqq)#5plV!*S$bj4j)}XnYg#Kun!SM z{(#(KNmOI#ynCEioh^0k8iFti^YOP@;sjUw4B*9A;AFMx)yLCdA*gVNn@!4nZIwwQ z@X!vW4?~XJp-cdF(68bAspt(AiyxJ?U@>n3|}UC-*i!e}@cCWkN~ z_ftwj&O7tT4Xe&5Q9)N*a2u)gK+9=G$wK54AF&KBlhZB6;n&7yl$n4>2#D}!`?ljr z``R4Yc$uj8;YfCPOGZ!r{9+hFd=Em4*@`WtqH)F(dLG6|7tQA=4}FL-PyU!*FXK{a zQ?v@q5Lg|?uyHQ)32h_i_efd7CyE{mW^5S-9{?FCn|+9i@UL03dGd9$nf_*`4YDhxYr*aGIA5-r zJ}7|iaM5?06Zx4@nYdq~fMLEw<|wA6Mw#34q1mgTLyC$71wCM5&q?MAf>6G_`b2nR)H6{T4uREaUz{>bAEvIl z@Qq!%GQvso8sG+_&A^3*Gfi~Dve35FP^uzuSz+JuvbfTw^nE_g6l;N30=I#1+s+=2 zEqL{f-OET*#t1z0o=(Oa4a*@n5Mb-sV6oGM zTdRl1VONh4h&vtUj?n+`vh&_)NA*Xg*caG@5-3Yvhfq%Muvo;!!FGQjPMqkd7CPz! zsR_(#Q%&=fhZ&?j!MZg_lT88s3hrQaC15y@lxoo?2Yj5OTx+tcI4FejGm8$|f#>ot5ygImwh6mW#dy9rU0DaG&vC*g&t<#`Cy z_P4Fap3+JwBw>=6&Uzk`JKeICnJHKfqP&C0ZaqA|0|x@L&fElXwd=*{m$i&?&jLkm z@IcGaZH6U*)2V0GxczdCHRnVzLB%kWqLe-ZT@}D{ZqysvivgofP_`HnuDnPK^-{H< z=|06MF{nHCa`X{oQrL@Z^=#X~lOfDzW}}EX)AvkwLK0hZ=}AAv$sw8&sjmd$I42K2 zLLamY&wOYFfKpMcTJwYK*3foof|2TLl&a2#QPjx7ihg45DIhWeS+w#UD*b1mW2&r< z2d=|GWR;W-IG|z|&!h*GKfR&(T2dRHA=2T z!=ac#H;U1VgAd;0c}vJ?r;5IH*3veGC6ekZy+eCG1YS=E`w`g1( zg?+erz9pIu9S6sJZqU|JPtU_USz}Bgk7jsJ?-!sf?Uj0#=rT|zeL3K-VI1Zr1bodS ze09yj^ValSzxbXfL`s3B`eaUURY7bM>idgVp<%U^7}@}V(vh6#GL=F5xKwhQ1|1`G=sLf7}5 ze$i0O;-PS1Hd1}w*cr7vknx5QA!_$2ik`F~IP_N4;t?|nk|bN7p|PDIv?oMrv%Dgp zPpN{HDR>^{NJ#`xOI0?O5vtWa(u98}2G>(<~e5Aq61{ z9=^#fA${9}P)|d-7=i#JY{gbgcSVQlooDDP*Xr=RN-aCcM{R<&HJvbm8*Qc4yLno>+}!g{s+*d`}&F^oi!cDrd>9K(QzJAEWL3YxgD zUW2zc%d)h;p^(hKAKap94N{*It4w3l3`@%hZiy{kYDUrRVvkI=k;o zI~EWT@myC;cRSPHqf&L=CL%ARhFxUi*|9Y5%9W~YJ5PN6+08@PU8njhYWd8t%~4^> zyEJub^-}ZjVR3P853P-!V-b7L`u8}}3EXgc{fIkS+Sx#gW@x1#bFFN`!(*rTr9$j? zUeQ^QjeVe~qK0x1UKZj5%y88kc7h@n!{kzb70Fu7MZlqVmZ1`Xt=jU<_q)Z~#i_RBDZSlDZ}{rNI(G zu{n$BK4jl|1^`CbZUFzNzKy{(Hg55ru(D-+q_1>P=-iS_5;vmS8%SLt;ALx8mzmEW zW)A5FJYbE3Vhi6cB{;A883%HO3ys#7675nPoO4Sg1aR%cy;Rm46R66EdhZlbE9kn$ z;GPv-fzV}c)kN5$_E%6rC8~Bjyz%SSVDpv7{HrTpfF3leZ{io){_S{^ynLRgaq?91j0?yY+e9Igr2g{>mCa$##W9JGbU;$@-y zt*NG3Pm!0cx!o>Qoppq>wY7)vxhsFbtMNO+NXB#IAxG&E{D_7D-`t{cSh)b+7JY*W zq~>z#_+wEW85=BCvecF5vG1}lg1HSx8pWLtUcu5S6aS|Q(wGdZ-2Q4%t_{!9Rpgb% zvE}N~^ZFBQ384cPLc{hCD4kBS_%PeK4u5jI3PDr;F6F(sG1OXY(AJuxynK zmQ;01IKv>(h%OB)@b)m|byl6?Rw{U|IXF7l2&_SLlJ_cTCR|R#+HRO8M^W@&>;{d87Q%4ajKtug8&OJ3u<@@c z(iJ|q?Er!ZtL?TTjnVeRIvP(AMK@+5jNaU~q9?kJmb4S{9L zQEvcBr)$v~w8q!+@(?JuHw^_#QJ<4)e`E|b(0uF-{)e38^A0zcWYcG1E>TNXJA4~m z*fcl`R~I93(V;q`Gb?Z}LgrbCL7nKf{A~LPmyZQS?r4f#?O-X>4^{)c|oQllP427k~3SaU;Ty z$mV@vU|BPVI}TRd=Gf@F`EBXYmS1NEL%#;VFX#a)Wzdd6=zDGamYP`Z#d%S{g}rOw z2SAYWj`v2~y}LIoiaqaU>nk-KdgJ$zwO4TSQuAVSL3NZ0Lwg1}Z^XTuuZ^1W8%fd( zZa0!U?=BcT^~x5FY9&2rlrY&aNFcC^8mJ~6QjHI#@iZ)nI>3N)jA2jtyHI?b(IB(qq4~`=)k?~x!YYJ{}t8k6hX6!TE z0ga&)usT81qMV$35k>hJ%3Fs-G`i$F%senTkT1X6+V1ya7a|7?>>Eikw>B(!6 zl0zdiUZ|6YF4`<1>ODRq`Vu#_UA9p;12e#hS^9ZuhoXQjC7ki$oK~=G zd$qpIB}6$`lefvRh>YVVqaztcVfaZrHM8yP2rubwT@s1#i)xG^iL*mY`#ubVyRaE( zBIzfSLCSNZh0qVL4uCdN>zA6nVN2qC8$!XKqgWKf8&lRR`jrHwy%i^lQGy`iGoClco`o1FUp^Iz6f)Em+f*_#!g{ubgcCAa zs{D$gHS?CkITT=a^_Q(Vb4DR~d}dgO6&7yIZ?X4YbK&jL(}snRuf6TntWsplZ#hwv zME(j5rbY``H5jD}Vs%xO1-+;awMIYF92YSu@3)y$`VfGW5kB5SIWfCDlXw-t~x5vmA-@cI-$ zzK1+ycn`{94iWZS3#-9WfY9<1LFa2qH}{ZWMSJfuQbc)SnRWSwj&egq?`X__OjWo4VibY}qWabTyPR{5I zc9?XY`Z-g)uhd7HkE219ARsA;c&oL_>+8Xe7-)4Zi5dMpcH({iS# z#o=@M=qoaG!mq|l&(N3H&}2c>RHe~FlhgC_n{fhXZGt+-i;#G$<{V#B(XsWH=+|9~p=*Io=dNSf&v`X++hy*qde)l#m&+2upK_&7hv@3V<|17d&PL@oHj=` z&A4psbWZ6tY;mqBy(`8xV;MYWM1C@P`k)n)UIeF+b+w~EWH}am&3k6nyhOh#$iP$Q zsx9qUC`7S&>l#;v=tkJu>9VF8_YgEyNX>!F1xp5*u2>_Ywm$TQ)wn6oF}EnM6SiYs zK_#nfZcoqw%Mo$xbJtxsybxD7$Yz}ErY|XcX=buKdeO%GU1otqFVZnx zmJ8Z;?&t+t2=+^kyHN^-4a!%orGA{fu~n}hKXmwIoMFcn@jZ26mk&7?M|tksKCU!c z=Y_<(tU0xS(zZKp*s+hvVLjH_S#dk$1SdUr?2}uw`)V(ZZ0_;#$?3wKo!#DD$&6)t zO7zpUihREMm>vW&x1+(ePrd?NYgAk_VA=?Cl&N7eF|k23lXW_dMrbo|njepcK6ejF zGvR2qFA#SMix?qRH%UGhHsR~n;AQvJQzgW(%HY$}Z(2YEUhS49z)h>oVLG%|Y*rVd zHQsf@Xk{rxr^Pz1D}a^zc-Jyo=Np%yl$R-IayWI0_^I5Uz=uhaQ?|#`Bw|^hf;T># z6FlzL1O!kzbm*fiHSLkyPY_idi+VjXM06vgDJz95{#ylYR44gppG7g0|1l#NXlsqS zq)H(pl=Ivwno}@u4lntw)-=SN8<9OX32q~Df?>589PM+h5q%hOKJ+5M7E_DXz5M!s zHGEEE>zZmdEwUNIq7h{Ih=Zv^2e2fP`@fs{oahKAYZ^u`#Rle%Q?cpRmCE^o@4M*OK2+>? zbRk~$raUFVB&Avq-q+9imwJ|UBF>y&C9-yVziABVuwl5Lk9@dIsV%dRz4*tDDfr;P z>K9sbrygWdz2vsUfEak?Q~-mER?067wF<)IDZqrmid(i9E-x|jO?Csnqu*a{hFfpj znx1>=JqHgM6^m%>jI(o3Xo@seEcK$bj{qnRTl`2fzFvfGw=6{KVmLh@!F1oA zqkb?HY8TXbruXd5kVfGUJ=z%k16y$)U9Sem;&Vngk9cI|PI*d!F~q_r0(_AP%~AEw zmP~1(Q|7XUuZ}3`4&li|Uo+f!I4`z?SDxJLELh1g8>076p2K^7+g$)jK(@aj7f+Tq z0B@NmyR*-1Bp|X$P6E++$AZ<~Y6V4LKc)irnd)8I=k^i>Ji7H3Hq>&*HAcBa`47)q zn;tX-wuf*Kh6BX$9VasSIuB3gUo0dZ3Pq>0o{i$Tx9Vi1e*Gee5uL>YUScZ%oRZ|z zrO;SIcALa41eD5OcH|OgafgPEv^nN}mgH;?b8Vkp^AX5k{D@P&J(g%k-`yElw-W$H zw@AK&=rG$nEe)K~$t+VFaBW55nz^uq^cA$N1e?#Af$Sp(#nCsii z5FL>~L{!mk&oUq!Ko`CR}% z=xcK%0>V1#I8J6kg!FUKL)#fozlNeVjXJ^Ypkj@zX*mdj=14au{Rl;r>Y)q*z8JPIdbzntL7qL)*>*PZ=AODbgNX6CKVHEMyDw$q$8YAPYSOinc zIIwSEO}MFqKL)CFG*CagDZa5+cm5*J3HGb-zF>pmNL`>?}zz3 z4z2}W-p%hWUkocZ2kDwu$Z%CMh~E?VmD$ImL4!jgVm9NDS)-7u{*c#?mqbY0k3%tz zOJj@((8C?cA;QuKh$Xn;^kr!cUUqAttr9eWJ)pn2H|;T99}FC`Q2_93(d0(#y_6Zo zNCZ_=Z^A?|*YI!7SUfcffl+zW>%>GVx@vNEfcYI$3+XfqfO-Ha#b|(jGs8qkXD@;x2o~x2(D(BJ5kQL zeOtD&5PlA_n~sDPgx{`Qj#^2H#UiAPfO{IDq+5s|&tW^ONiD0cOm9|~l{GK_YXU&+RB2BN|i!ro1i zxps8vX6_uOsRxp)Xke<|~yJpxeU;yNb~Fi#B|497KBM08P00C4?sasq8N!&SvV6f>J$H;@y81rEpN z%Y?lwZAd^qkW|}mBp6T$R0{Y$DVe+GThQO*$RW%%r%94{3Wsq z98t2Gb>8(glm{R+7tsI=8wA*6COFbcPb42~>Cu1lETg4T)Bz|*Z3P=d5ErM1>P@<4 zRXd3Ig!AS~Zh*iCOlZ#EPN4#JvUL{ZJgJ6r-VdQ$hv(L}C5>` zg#xN?(8o5_&^P#`JCm|t2M$}t^pP>RPakf7()|(@GP83a+pfbLm0X8kCqAg1b9Rzq zFq(6vIi!{r$(~#?EEtBy64^3ouw*w5F@s zZnyoDk=l%FNIy}pHM29imhsWxw>Sn!5OPvRnI>uu5oj|A-QvDIFVbNX6t|o+K{)Ga zD7V|b1_~GVU^$?}tsNkgQKIdT4Rce+hjlP)vXn9}g!C+F85X!{+tTe3WJ~(70b~x| z(**<-_A0bM%Yr{S9B1HPTVboUFamwuI*9~Dy-7;707D}|KnhrGQmy~ed#ugcvB$6W z97c=73L3*UmA3|SONCk6Ck#;^|2C>RFkR!eu~76eAu zlwbi$g&|J&8=l{6VfOy@(uQSqmT8CjtbBCsSXpn zaKqXT#ey45wO_-z^A=RLBf?IN6FiU?mV)u$4it{Xe0vULO=db7QC&69S!tNB3VqU& zATBgbCg!EX=w=sN&~?on^BSo4tiTziDi{DY+MGXma>$dUxfv!Mm5G}^YkyN=qzfv8 zpt+QmmVg6h_zzdu9_I5(RTLxSD%%mQwrxff$d)jzRc={6I&_9y$M^7{z54aAx+!jk zKjk=8fH5SODse@1F~NzmiSiah%LfGfU_J)TiBW83Hsp%Tv7t}QST_@uWz@;(?r#=* z1$gIO;rYt}sPxDhR(i+_01l4ZsjaXWV}K>#D06cRj9hcgryXv?aJFvZltX0G_iSv( zrVW9^96}R~8JFmkO7)V`xD6~9rjx+j%3(n!1 zJ~^bJP)0l~ib@GExiOuU&TfQPkC?a~2JjFrdw6|vfhAl|soMp6H%#r+D zPjHXNammr6YG8_Tt>39h*`bGpK7j4__n&q9UPd?3C=M+S4$2dUM%mJxZUUg{XX~v*(hZI&|zFy{wNm-gvjYj z*&@0K1c#ikxN*DanxyD(Slr*l_Mu!jttAti2Q`+>Wm9@{IKrUj6a@8RF{-rB&BzQ; zVstwrec`)f%vD!)?=4_vmgou~(MHNy!z>BlrODauBgyVS#&|9BB?J>n+zePg&Hy48 z2+>$=`*4Rnw3n=zzyMkzxK0zy?dGTlFSX>+i2}=(0&(jH9_SIh?FoB*su{FnTXzTR zM6h)z*r;Wjiq3cGoCXf`0y}PwV5$N{Y$KeH$5NG9&s{Rw)_&T}B4bW%*%$>C_k1Nl zAoh^<68!$hHpX%K(DkB>IGy#egKIMU z>itYWps&Zl51R=7`WcU#0K?a4ub%QFoBG9-jp>@i;h~^F}y$t zkgtu4QX?FiqF_#_4%rhAHhq>@5*)!+l~0q0zhu4EMd+Gp8A3Se5Z(4iKZ!p4({EJb zW6BB;!cIH%CchN5?)N~Oq1@ct-{Hv`CO!)nt~tAs0{|5k-mKJ1>g^mzgfMl&N}AS* z7J6YQ6m)h5AdE2Y1Gb6&nhyF65Etq#d1Bs2U?f|ehz|{|v{sH{?%`XnjhpNB<<718 zT8o7_0dBk6(&sAe2d$5Zf|Z2EiWXw3x(*s@I%_XzKVoBAM=l#gk+|fW<+h8E|By5Hk1V<}LH5$-E$*CuSHB>>vgf*(UA`D%BN*~H=m|Rde2~uVk zCJNW>cjWDu!$h>LGX*tMXho3G=6&0I^IX!)ftI=ng(%US)kg<;D+vc{rdXdRgl#Ei zh9=bQ?RUhOHQ1rAYJl_G?NArzPq$+m$^;rM2xj_fwE{2ev^r(IUOOo4&O#II9k|)( z8=cU;-0SNT->8I`g=Hc5JHA9|_>_HTSs|uTTv%R35foW2k!)ec!S*|kt!EshEm#`P zjWeo{`Y$qKQxHo?q`|alC=M%6#{?0#gd6h|y+iCODLkI-C&)`o_Mw)e>Yf2_9zSQ>E zga^LVp{Zi(uUIn2tmas0bG>WRWr<@D;DyN_Xa}AFmQ!kfXezWMc8Mf{AGrjJiyCix z8(=;5`3J?-(!2$ad#548z|(fIdk{>0p~DVJMrHTZ)Xx28P<{D>MxHBIQ2?9W#>1>} z4nEE@C<4MkG9_IKESO`1ra%|FM_MZ$+meNm0F%c7?JX)zSV}02k+8l%FN1;6`YJ#8 zU@wpk_5h#aDr>0~ixbWr^BTC*It%Ot8RPNV@H4*28VU13#{phR zlxVl+Y(=nFFsYjapT2j0)H<9CVda1zB+5!~AM=1DoC{s(a{#0ei&o!DnWdY;wovs6 z*F7u+FaRTa=SGpEhypY$5E)4^I2Gk5hu3GEmfse+kk`sb-7lV?Q^C)n0bLbDt6g}b zDDnt1-NIiiyqmJZTQw&3wr5s`YybHE{Ljgo5 za#GjKGHEd{Gj)S(xcW*v*b1<72A$FAjp54)w+K$?Gci1W?esq64i-#=8f&tw%ZN0{ zqwU+>*J8%hx3Q`GrH4&Mv&Zna&4WQoNf?ZZE7P{c_ZCqp=}H)yNj^;=*QC(Y5=rQqI}W^`k;sFgK;fZJ^-6#1gxh z3w$A90KxF$x6ojkv{9Nu;2y+;lZ9E>wch3#GRM8=DStqmMqU`43Yzr_@Y%q=?^A)B zvGu?K;6cYjwmaQMpFm9~Mlj&2EE;Fr4aT+0M!?LnA;3m&HMq#=oUV-ATniKYPx07J zm_2ccF5TX@{h~X7vm?w#70ec5DwjzilBE=}cv*z5O zZRetQuj|QAO>dA0NSQG`jhEf5+qQDc0NB^j{@$+P3W&I(a4OHHwXqn5CbO z=&K14R5)JB+uh;}W>72*GDZ!pB`GnY;Wfdsy_JHMwl$9A!Dv!AxFK}LJ0_r&lEnMk656BVjs2C4jlX7!C+Ow?LT}o zyf|~(Iv8XM)$Cs|i!PDx<9 zHq{I{C;GmfuSC=m*INsdUv=M@>_k3q9n=R#X=A46!3H2Yz5&^@@&xSMawJb*7=@7z z^szUT$i@mO?$&i8sp$-TLNsV?Cv_x5burOua(!em*xd3xWJ{yjn9mIz+20N#)2fe= z6vru{i|LCB-y!ow9dgMJ&C8K(9+gzK^$1{g>i1?IELou=+?8p2t zxaN)xFI0^)sXMT-{y+V=Hy%!lCcw$Y2CPDFU;Qp?YcIa>r620h!$(Y$&zU=Vf59|Z zWOuE7m(V-(#uB`7;m$$jv))-6eslL#Yo#0OR<{J(Ut9~H2|xeR_q6w1KL^j5(H&RL z^9}Ev$g9xJvF+A$EJP&?fw)ckWU}BZpl<47gs4!lX`hwi>M^mlx;}2jI~OXwGA!dJ zq4eh5xJ{{H>$MRWsWK_zYss@t>&t}>tG->2Y~~)jB0ilvRJsOKKIZfa;4(@Kqgc7= zgWjMQ*QAZw#NrpTz^PgDLM(ccpfIv$-y5&LCUp&?^i_QBc(uM}C>S%hUlqmzO%2Cy zyZ!~S#I*(ui_Ad_UQ7>SIWLO^M7Q?3Nl9Ddq>M~rzY_(dq`KdWf!p2=M1>C z5ie!P3N>zIjoi{2KD*VJBXeKX8aHk!?}%A6q(#!DOpa|bL>NEMsz&=|0O)_#rLUdL zog_Td&!V0~7kLLvs7MzeN=I`@q|7fex z;FXerSDIElHJ7;Nj?&=T)|F}8^_jS-cfYs$?AhWQzs5ewk)3jh4zXFqX;DL=HHX2)0jwXxa97C|2DD%W1*2M`-) z^87@f4S;u(xn%@Kl_p5lIC&s-Z2nVR%qPDm=hr>UYcHMNe#v;lMQ^<3;^{RU_6|l& ztt+mBa00KGsdxsnnF*fC&mzp1pWIE2L!)S%h>fB=faz3NWt9UQBMN;D*ponMc@wC9 zuc7wsuGGQveIwR}abE=Xs%%N8!qkY)+9bRL=$V4v-29X%W%hoVJd+;sqxEZygt@YYLnNFd9{0+t|W`wYx z`wY6*Oxu{XZX#|36_)p#_mNU+t`j~0A(6VT4P;@C6tLy4VqWgWEfX{1UO@2RNO2Bt zw!Unv&s%%6F^h|)ku8LjTpXJtTdy!kT^$D+_u51U=c+ZuP_gFBW@Zt}HYhsdjjIC3 zFs}NiL>=5rdWnN~#T3OE6VIqn&iQ?#pWe<++gA8eSFGc+6`0%tL_frIOBY<=YAPs! zN0JbXeYV-Z0Ym_ZZYItg-aeeNR6~~yp$CYu5jTSmL&J4=j%<>UMchirH@I1cy$f&f&)F<7_!%s#U)tfLRc|kKMI{SJ;~knJ%`? z`Hwt3<;)_bI?IvQWvB2ZQ^(X4vQ$pVr)G#I)^sYr&-eL~6bK&Uw^j9RYaHG`ur|Wy z8G^QuHI)WmNKhUspj#c=x>o5Pf!>7+ebn4`jKU8TteD3OlhaNuGUZT?>jaNT; zk?AMl7=+oLuu?nUn*OZCn@@h9F^`t<#AiB9iQ)2^`Y_p!DG@fjLi8df%Y>saoi<6dU;bT>ZC0r!w&x4x@%3ZoO0@tX1biDJb)9 z5`?bC2yjj|-u0Ko)TVM66R~JIubm`>W9%Y=$8usPUjf6c8D8<{-lEIQwmw>)eY+pU z;?hG#iGj^G>to+^m=$_ic{^DfH$L3k_HKIcF@Gz1=4)z4YaDCuO!BHHZr}C=VxH-3 zWFJ?I8NA)Bv*jQo<Ui3z+^jVu{aELosgv5=3i^wGH~%7NF| z++NQ%l;9Ge4QPPKn7saO!x%$ZP z=%+GMB$2HYQS1Hc$hmJY{QWv0Tdt<4g33esZt3C{gnOGPvs~BCyq2Y|xFWB}oEO*S zsR&0ZhYeP0_0+s&GO5dAYLjGy1r|7)nXpc6`Idbm$1BNF%bs`dAkyLi5Mz#+Pfu(NS?-F>d(HbqNc#TUjHHCi8fK=-HR zmZ*=g-+SMooIU34_jxGbhZEMK2J=psZ=&B?I05L#t;%6prHQ58ZT-N@%3=Q+&l{)| zjDZ5^^(t{~Lb?XTXbiy`&Kv)t_~u^Ym|zeE1H&*Mb>3-8b1X38r1MPEhp-|YHZJBz z^Oql;zIb}%m2k@^s>72PPk3UT>6|5gDJBllfDUo62z3b}#lnMPTuc`~a>mP3w{IAJ z05porANtzHDSeIw3;a&C%0>lO(O*&JQ#3kd$!g#^rs_p-tYf}LWhs-M<3onUN}|6N zsl`*NmC9M5j|@ci5G2=q=PkC4R8GU1KKgrvnBg+#38DnepafXTHu%SU#*?n)mqNgR zw<6p2?quCB;XBrzz_z_+dcQW2R6?&_*+F8-g9^iqM2;#qlo;3x{8n)4gVkx7UrD02 zXS;mV;3lG*DVkY@&v`N{y7JLxL~<^FwXd zv|UpjA~TF_+6&QFGNi$57=F%{(vb6K(LP~ZHDt^T8OCO{v|hgL8W`UAOCwblEz_6j zJgwdx0V1S<7I)2vINv}whiQH=6|X(5&r$4>aeJycMjmSxPx0@wFnKLDG#+p8Za{&*oTlZS(I zz-<2GmGdccJi7Lx23VwF{X`#nZtvqX9~cm>0W;im4p?)61)OtO9j$nvNieyXALd{0 zoWJt8;Z={W^RAD4f(K6rS58MLn?*WBP@=jL+A<3><(55cs_7b-J~&uIYr~g;g$;na z2La$Nu@{WxvE)dL^45{H9I&69w=RGDK^TSWTU0E9)Y*uGT+p)|rl?7bSeZJUG7b!MU>^H>YTF=42#)}*=B9-#I%H%n`<4O!yxu!|4XUl`b0-@m;|zle{U2$_k^{*u!;qb z_pSe0Q2ok92u8|gxm;Piu(m1|OP&Oei?V*jxv^im%sEHgx}ArYSH!~PLsMN_)&0g>}_}FfuR8AY5~%Wf&8{^z_ucB+il81v#^h zTp5E8zFTpw`r&qN-ES!ATxeSy0aBE@EKgMAYFYM$M%!T1Xb1(5Fk%vM)maf!^g`Q+ zv7BXTQo%2D3O?anrEMFxtjc)teB-m1uIKqRgES1on9elb0e!fIdQm2}Id>?tn=GV9 z8EcD9ELGEnSU%lU>Yrt99(ZT^W!qy$oYL8V*IPOm*3#!mDtmoQ#jJUWa%8Z&V+M{3 zbM18efyvNC(Kgti!)kL5R)xMe6C2!$y7`mNhRm;z8||?JLz10MtvB4qNN}IEem!b+ zV}^)E@it%9uegg*Het(}jLiDiaj6}+5f!&hh;@LcOb((tz4KpV|CU||`0!Q99Q!?_ z3Mh&qci1XZ0QWd97v4IX_66rKaQD1?TfOcz1l3vy_8)1*HWAo$*E(xwgAU@LeYeL9 z3XLo-g7;MJk`Lt1qk!I4f(4UGxu#+0eF9@pM&l~$G~9T77WCODNft2j`jn7V(f(Fv z-gJNpe=%#M%b)IQ@zlx3o_d!DSK8$um>4dXmp!ti9r>UfWtat=IM-aQ#F=UE@-D1` zA=rDx_B42fM)655(JSJ*6V`j?}zdQHf=Y7VEF;}pkvHjIaex;VxRc8~{YpsNfn~nvfUUhRzu`-kn zgI75B(Lr;t-VRI;PF=bbb;b|fXQr5>8t66o6c-gdw4TfRt zed7g0Celc%S6)m+L&VjPU3dyZE+|}Rwpi6^eCJ07S!(zr=uKkOG4I-Yg@QI9(;~Y9l=O+yV>V)$L z7xA=RSckVxTsga%+LI<%Ekd(f%Wx)Vo4Y^83H+@>d+4Oj5fg0)Gu9w~@DCV-O%4hj z?0x8ycNrf*G=L|YHm1y>=|mf_mjr4~5pxGQ=7!=F{X1rnpp6hlPO2)HphRMEVQ*UK%!A@hmDRzFyVEs)iyGuw8nYG%RfVVOvN1epk zTTdgs{MOaiq~eV~l+S;7ImXC1J!)c?THabuD;>v10muCz* z#YSmzwJsRkOt`&CJ0i{Xe<>K&CmYWx?b7RjH{8C4>AdZ849sXfTaX#P@O-Rc%jMGI zs=jj=7{~QdbOyuvHe1AAIR_pQhPiQ_=wNQBl7qxeTF?nXh|P0R;6Xke5ZqHTno9zx zT~I_Dtusn%?r0mwn%F+qvMiIWso5A>12AZa4#%59jfU15>q!@T4I{8-NbIWRmUgxm zT}2qWH&(T_?cFIr{X~yqsrVc=)--g6p?miFhKw^Cvs_+?SdPX}MQ7)3r;C*EE<1eA zh58^nl-fP z7l!I*Wnoa^H?_%E zr^9P+Ocr^-dt60C>cIeuhOS+sPc)pZ7@*^BG$FZU@uM*k4BuRubr%u*3F~N=qOGN^ zU0eayDFIe%VraXUrwEJLVm>Jg@}&jHL=48j$nNFe6Mz6d3+(mwpxF|zmyh_O!Po5R zBad1Wjq{v#Y{6k^dAjATIab3eZjS*iH~zeM#~PB8rUWh|H8yc=$IFcrnyvnl&cQJ9CE!XpQnp}*Fy%x$5Js@ghEZMky2*&*t0b@9R5pTt( zgMH~tZvWnlcjuk?m^xC`ML$I()#CL*fNd&$&`x5mHHqdtyF?%e@xZMCUuanX*ch9G zx}45^=1q=5%!dwSvz>IrGBos-mlB;8(jxHm_>2W;nb?s&9Nb}gea2M}T843Od|_5x z!{MHi-wJ`YBz%fcuah=}fG>3>e%h_AM&IEW9n&f7GuSY5o3X8PB!-Rz8{rA?ED zyWqOhjOgX7JBMuMsO{$FXj>)DH2bZgHcTC?R2!0>1f4^ z9j!taFQ0tq19jCI zE3C(q5+g>PUsUnFjD?|I<|nP@M5T#Pxn{2PW^N%+CF2g6HFNi}hX6Uw+eG&tyxTql z*;*bReo35XHTscH?KkGG$|A#TdQn_!7!U(`C54Y5&36 z2WyrFln=US+9ftVel_Kfwi4;=bI4@Z^|Q9kCKo2zu*3AKLgHK%++*7y(T0<%2Ho)3e;O=RH&-^(S-tvM0 z1;*eirR;A@6eb!6uHhj$2bMt=X^th#donb&xP39a^P8u(>$OWzGVH-1P+qFvuOeWG zLmvvWmstb@({k@_!QP&}_JD%`rb7k%P`)jwKlxltJ_AuACs0c_Lux4jqr+JbksRnJ5WL#}Izw_3ZgiaT2$Z^)opy zc+N47(@(5epfdlh&mX32Znd$(=*b8Fyo^71Is&ZW^uhoSK3VZQqX~cl=gq5No#A-~ z>n1=Hr#{2J_zn-&Ha48MEUf#|qEB?1L$<99g1E&(+ELWT(^;+yU8)s)`|Obq7A;9v z*gQezCSxw@^}&Ndg|!*NXNe9ftA3J9O!OP@5F0Iog8>}zvS|*MaxR#7#rE|ZVnK7g zPL91O1XSNRdJ#A09D|H88lzpkbiC91G7Y^b_u)jn>ys^VF&@rSPqXk?pANs?CCuo! zL9RlATl$a>L5oB`wKbbh%7nL*!ovBj7cB^G7}_mXwLh8MO*<2F9A5Ojn~l* z847x#x9KYqJ&cU_B7K`1I?Q_D0fI}N#@fBHr6sIS`lP_pLNP(N#S|s$OF9@qe9#Be zgMG_-?1_&^YGIAEaEdTQ&8=ULTef!|Ud6n?zWNhQRe8f39Inc-g9rO)usXp(F}HFT zk5X%=OCFiCzW#ks0i=snqKn|V;T^VV8Q-*mmTwytj3Re!Kl-G?FE!Wp1L&;+!y*^E zB48j=rI@)`80~Nc$9J%*{q0!*_6OMYD{5T@H2{Z>zOgPnedVsSkA|KWLn=dWi)rklB5i$_*a~3uuM*K5i`2 zUMyxrz=-ODuQ{7L%Z#i9vl8p-sS^FSG0{I@^yPrLPS|n%zJMHE zsyUM#2OTr?ore##SGwk$#O(p?lG2^Q=ri*Vc%<2uUQ#yf_`L6^;_Jyc+B?%v?DEgT_Ha~K+PkMPKb6t56Ww2yp5`*!qxMbQME zyHwz#XVN6XAieFXtAvG`o$5StRjrn$tF&m7*kITB1gGI%N(M}5UUp7Sq*qs+$yG`q zx|qecH(Lh06;t$)f}&1OUz>p$pey>a8SEkrOZ zr$u)xOmq#~m^E1l5}N1s)sMGHFJeyekDVeq|C@X)VT3W*#lUc%(Qq|Jj)&uWQDxv{ zQu^nQjlnjaFKbVO*bmra>G4nA&U~5~v5pd=z{tsW!SLRF;y{<(*LR8O8(1A5Cl}XO zNVZF5-)EaAozx}vV0%=0bBRyWC1wgsESoW5kvgk^^Cy;2i>q>^m65$H!pb6!p4=CvK~70DhnM6H7m^p+#;pu$dY-d|xRecxA)!C=HTYUz_jr;xT_E5mR4W5H78 zw0_)|R~Bx}@v$Ww;u?k!4UUlWR(h{V)*BZ9qdY6sulLCpbpqUV2t&NX?B(NiR4|!b07g{UV$+gftwg?jJii zg+YENTj9Qyj=qf1W}MD#q5`1ALrL#!-g_;_cS&@LbIl=h+j|%G8(rHL1h!E`GBX~W zdBAdoMG8bWxy_qfE900WzD2NsrADk3AG1YCJ?KKaNu^&r(eAW3}kb+ZGOLtf6(9%P(j7^GP%S zqBvOq#u!3#$_#3p-4z$A`0jTn4PrR*;6aVPw=2jOBX1Zv#u#8;We$SqUIr<~$#ke* zPRjkGUF6=5(Z12fc!h;~3EIyjCa~~=i!}z;i_rR%3qQO210~E&a=bX&l8mlbB5;!&1r2yb{v>JbV1vXClWTE7S z8Y!b?7Gbs< zD>6b7Cmqu^i)5lrvx}kx19BM^1v24Y9(b=>xJN7(5=OMo?Hc20-^vjfQv16_r{_-U z4Hog--C_(Jx3IZc_np$1Yc%{2feAk>mxo~A5VEyeP(+=lxS(hZ*4~k0B`=NZwub!o zTe(e5q=gJKy{jTKsT|CQTdFK8PXn8JvOp3U6N=)>9xZkt0e3?x4Izv0G{kKn@}d zS&S4;`f6uyT^D4-@&JDT}FLMLxU?9vL-&}P2T$9fCO&7 zbz$pZaeA>Y|AZG49A`WPbz=iE+oO{wBj>;b|c=Xr1=^PtIjR;^yrr!R~43Anyh zNK!-@7xNFG=w^egsWxpd&@$)fFZr$+HAK-ig8F-bY+AuOwU^UuaVYA8l@KDA=nkLD ztKf4%3BJ-^2%BSD+lgNKN@$bqr8P%mrr=y}FleMq7)U8~WpM6rzAmsJP%TdEjoGp3!!|`eD(6r17m>uOCwFu- zoyM2#d&w6gUoHhvGdAhKnb6+YORghtkvHDmMG(4Huua>Sz0xBW+ME5-KECs`1c?&u z&2$f$jF4PL-tNoi_j7%&tdqFXHd@_I?c!|~*@VGEL zMHVjGmJehkxM=2^OT}D5*e$=JW@5#whYaJcOR)JZjNHjvYgVyKZzP5Be0mEua5DSN z4!(|5Itt&g(K=dC6y*B`JiQ?woJf2>Ab;86Daq4h@nW z=0=f^qc5KJA=_AW236ZOy(L4_cUfGJ%1~gQwf86$^;7)S9(&xRgk6DeTYHLg>;n@q zQsNxgy;^W#iv!cI#S=buSbUMpW)HgSsthc9IFDuJq{OCbBBF3v(K3`>b~ZPP%y#Bsks+_nTW7p063+C zJaHDjoveTXm!gGrpwbSI_TG&qbNH-j(A7F5$Ht`pM6WPM3$^RF%;jSUTQrx()bMs8 z0^n&tK|DQa;cq)39Zt%KF3%0C;sw~de%vhSut(oLz7uoKbXjg0Mk^wFdrZs9&kJv7 zK`h{!)3+Bz!_66k2iNN)uG_*fxb3UfNN*TL4@C|0W`ffYg+im@Ok4xWB`)uYZHu7i$mAFf56bKxO#>W*hl^GE*9$S zpoBz@@p@9caY*K&`Wt6IpWbb+@ovNK>^GB>=x2?#Ib@Iv1EQu*p1l6#er;QB%;Seur5!LHU8?izkNK{ABX9y zb%a3=-ShgN)%fc%`g}%GzLxC@m+Y{$A+2kCrvZKqt!q@dPN}oKuD$yP+mq8GWxZ%T zF52kIc{`bS2#hKy@Vdzw1-P=O3?} zuv!tNDoTMUOtbvmU3{UZ{0lh(X0X8={uYcHEmX1@w#jdTLV)P4AQ>e~6o`mM{l}37^6w}dYS>zy_*|48i2?85N z@H6Y}>F(u1J>V(Z$O#qNov2wCj;N@&kLRC0;TFOM*(~xWSZ?6 z4}akp=+TfyTV~nB=p7^DB8I3)07yW$ztR=qHgn#q7sm!LV>^97^P=2DTj^j3<6ohO zsq)4i%=fHTg1vXh8z*m#k!jaOWApjr+vZ4Wdf|(oZ!pGKvFCOWoUlG%>2VKfx#Rz2 zo8!Wtrno6ysR?IWW3WJ}RrBnt3WoJ2GHy@I2xO-k9Dj5{Z3S z;vlik4x+-o`_}ack-K>?#Ye8M_B74DnkOXb(J4x3i1=)!+d{H!zphL#J+MF`s=n-+ zX${cWr0=qd6pjGbMO;k9aODeP9r6Y89oiKwUazr2IoP?Cicn*gDzAqDMur&>BRR4zf^MHbnR zb(M_KZxgjZ1fy-qOpOD@;a1p*T-uP?ceGav-vOTU&KA(>? zkS22la>1?!``$^BSpMpLH@mdb-qF6hU)Rs-dQR zczY+&sVYDH<^S;UxC?dAU(y%7I$>`77^R|KcgQogDvLtQuw zKvnfghn;@@$cZf;Kdl(7=iI4_l~QUNN)uS%2gL)2hxKr{tWvQY zA~=oeTe(pR)Xk!IIn>hbVH1`0`hu0{8(Hd8rO#QZsucI@c>NPOH$mqPff3K&x?Y<; z!ZPZM6}H%&e0hWWaB7A9i@Y@EvqY}SrEc7 zp=1tvWG;jB3`X4+1SwWmYbR~nS_EPi&OPjgpDO0uCeBV|U;52Bh+>dEGU42O{(87C zGrn^J=ZABVF9h6>MGGV>UnW%QNS`=JPh)HbB<%YTPOY^wS5J^PKWMXh5P&(9>i z{?8_sSv%>yi@xv48^$(+AnRqCa@b%~N|B3yHG5Bmyg|N)wXu{R^-R}ZOIIgz3GIut zFI;;8ohV?^`R;4c=&MKY+v;YDRL!Lz4m ztM7Awt`eN*FELVn$#wfL?l#|yG(d_sq@`5DW}QsLd2BidM-4NWWm%{zr)=z#$3~O^ zvs~$Fab;QV#eTVJUXfB#C_Y!s@)2;uFv1jV3iieKh3RQbHO^pSP!2_JHlsd%0$ZgA1z1W&tX5bCi zU%&Xi0=R}zg&3AB&`EPGk4$c;7YB$!KMUQPHC{O!XpGfx-E|D?&tqcaExQ=RoI6PY zjhW=TcEv%dI{nZzuDfU3JZFH3O2vK5V}?Z}&vOXQ1@FO8%5vC}b6 zYaQOrx?S_O&mwPC^f`Ud!4uVifukh0?lk$rznWdJGPoD#YgJw+xa3E;1m$DW_%>#x zh+t|QkC|j58^(I}SN}Br+3@LLUiQpR-I7bkb7zwTWtD$zN(C2?lG~vxdJBzo`U{lF zZdlZkhe4eC7xMYef~xEf?EAQx?c$ggQ3^KBj|8GT#d@db5N#YIO0$X@)IkNwh-im< zJI$V&zNCa3Hd;kPu-(|2sZz+m^>)3X>abVA_C;~*y;g3)57eDa^scqmuZQc`ieh`) zdBYGNZvMP?s8Ai@Y^ha3*2;>mI;j;_-Ga)Xq&&-lk&5GVsY!5ahKsx9Dw+GJo#UqJ z#{FIz>AIO=**T7`8x3QPWAFv@pp*#C8d=#Uus}p1(%S^Oo{RnHizn37v)Ej7>n!W? zgho{H3P3z3TL2Y=r<$X;SE)@80$+JIaM^moW=>9^FTf$EFIn|+xn;US6V((^m0=&+# zEkNafGqDq^!H1afCtM2tO%gorc`Jj zRK?FlA_tABR_a1r_ThKcF-Nv?)?g4g@G%`V8GyC#+T?`mBxI1THH~o3umyJ@>|yy! z+ot{(S_FUO_Sa;d;_viW>pk=?_fo_WN}vLNAUg=;Gd!B~fF}Uo=1Mw@ zs*<)RJQOaXWZCkZp~;R6McPi?k5jkVPbBRdkEf_S?KV%(uKzu zq8reP4SOAQ)*yNj_(#p17>box4bRE-S zwYw+GCquXMJ1}{sx#mcw`%0g*ZNiCpKi8|+zxrZ5L0-WO9vqM*l)53Zn z&81le`shle$3@zoP2*T>bx*l`ReA@JHWY#{a9-+jhVLss*UaMV#R^b=S-ykPHR8kqi59SE%u54BmK@ zA~18h)>}KhhOb@^bP-lA*mk|XrW1V_jPcdLUh5$zb?Vx$^L4Hqry&~A_m6r8$%X$4 zOrk{8{b7yr(CAw5j!%KP1^-V+4^OlNUXA)iE z;aI2@Xv|nJ9{TeS$*AK?#7cBu4l<(t)G-<%0L+0Di(ps{G`F|1na#gzS^7A%p;AZb?(F`$%nX`U^^L>Yac zOmv41bg~j3wvQea??{hV4650?6lO2fm#y35moY>{4FeRFy?z~Yny?ILMZo51t_i_8Z|EDpm9TTmdh>~q&8?-^6L_&Dea@$ zl*uqs?U2l;BG|Zi2=-vbe5x0+2Lp3>G=HN}V)4=S01&A#KvfeG%bcvavhf;J;pW2#`=-~+2si-L8_Ap7Kpu*)GD80d65!ol#6ZuL13 zm^q*1+5dv0OwxyPJa*8q>dg+9qVIY|g8sO&kI3diha}f0UEyoYMj$gY_$wIs^BD{% zkm9RPbu5jwP13TNpszAI21E4)_l=VX{lj2qy8xf=l!oDp0_sy6NmWQrUHSsSd_pAl zlEOx9h*4X)Xkn`$5eS3a4b$7oV7?LnDHf(XlJ9*GJ2b5*)%8$=kxXl4oladmXnXUj zgtYL(zKez@jB$L$_0P3!@b%vFTLE(G8FaKqxnkA&;Hqa4R%L>C$(Tj4_0M>$C*jB? zjX4}7?^};8rG|0e9*=>SX-?P!G`4UIjqm1)AKIZu*Bp-_Z9_ui`>!Y578>I!Dc*ud z+1EyX$@o3BoaVD*xNiW1Is1FozGKM11&#GQB9YP1SR`(Sd|@bL>H;P%2*C}NjaHS? z#(o(D2j9YKps@+qdZqT-4Fe4{5H9bSWZBN^!|aZBdsR-->Tf{4gS}<=cK1OBiPevl zF8ykxAe;WoZl{Zq-u->5KrbU#A`>^dba8FeItLzK&S!j@FSTsDJbzElMa-pue|U4f#c zAMKkt;_UBD5^d*@lM728c=eXH8d@Z?3wLI^^P` zhso8?U+FP)L2w;q0&D@(Yo|0bkVg32CA;R*T#K2eoS<|0{nubM#!9^JxR|eF$Dl~W z;$(9SY!gmi;e1_aw-HsyRKSN_1#Eo9G(dn5FJl`}2%7V9&5dU2yq9oevN{t1IM>sN zi56m$GQhu)5LM|80(X6NMzgaguZ-Ds-tr;v|e-jG6=Sb-cB8=u>mf> z`+H81FOct~720)qEnBh?aJJ&c+sK>9Wp6aJH>=@%<9cRxP~7+{tkqwoc99y{FbPYy z;?W1+_>c}z5XKfOiimRDTa%^uh(TbZXIITsu~sX%J@1xxJ~8~f-j`A0H#8Ty>|aeT zzN0siFJ0xtb^36g*8wS!u1{{wzES%Hk#TtILx_v@*7#J&-qOD3JBpLFYfJ*8Z90GG zrAeYYG*m4$8ZJdtU4G~Bc-YXOt!XuNV~2{zUv|w!zUFN1Bne{|`7Lj1;KL%>ST2w) z#YpX(D!cW_Lod3}eqrEza*D}LLUy>V7JAcL9L}%7RaYUM#uD4biUR}@CFPgdL?Wv< z=4fa65C7&pOS)5f-V|H`E|^G>jc6^Ss0PAwqlXiX-OP>5q_8>^hjx9wS>@7EwUW_} z(Y{;>+}UZbkUR6^(zarSQt)z6H36JMfzrUNv+Zo%`X1{Y^DY&rl3DlY`-YuU?%Uk9 zIs)a58cvof9)<3p$oA&gU2tn_xt4*JJj%#;dl)ueQj5M#+& zZ^k)vI>y%onrI23>=IUk;Org*E;ow6cYN+E8zma&Y-zJaaJ)ZRJ%$cpbu>rwb-y4k z;s`Ik93)W`-a}nlo=%^!Cv0ZES_h=S&08O_(?(qB!Umbh#fWZnP9G|kV1jE(WXI#W z?fdbTfk1d~RTv!v7*VGgmmmr~Nxo%NE5j&Zw78g|Xv;*dR(0nSi}i_Z6s(@@V4gDb zJw4kdr}7lK6ij12!azPY`m*Is4l~2}GI`4`u0fRLt-o)@rqa67`&Y=OMD}U_w!usP zt~xNh;jZ&-qizFl>bsng;guC4rI%mM++x(i#>qh&_Y*g40Zhh+dCfWcVBlrMYgIFw zMRAU6fd)#~T=24()YrEtC+-rDnbpqyu~=IltEm#(s*`9kG*-lDGUZ4I6Sz3hud3Me zR_rImPjQzj8IbqyfY4mpF;YgZ$aU4Ckc1Wt zeXZ}KwXZTHsou7q&Jhuhj+@$?#>4=bcJ>g|i|NM#29#l_<7dr*=rlGFlf^_$i0-0d zGLBjf-D|v{KDbdex{2ET-DvlAhO)11F|+Cl{lX!WVA8&?)QZA<^x&A&L8ju!lwve)s_lBA*X@`% z+Fs%&Z}D3gRSdO}v;6QQEh}p_^2etJm&$v`Ad=$5&C`xy$3`&){*?I0K^@35UTo!_ zJrV3UlWaKT{azbR&M7Ay))$$r#)oKZCLJ?($}0~a&an==&}scT*|!?yCw+7&keb1$ z++3^`3Hul;k97x-J~|?ISGg4$bvo}kK+rK7cD!-AZVL!6P9Zc#m~Ok~(p^u9COXHA zH$U>ywb^JeeWD% zA%5_U`UkaO`Oc$sq&fHIunrFFcvg-u`&rk~n9|M@in?ouLb!J@mNh4a73QwbJ`L>5 zQ8Df_LV95IT`wW=-TPf_2#psEp-s5HAc#XJeB7R0wHH_l^RlULPF9EJ)cZDBl&7R+FyYP%Y~j}Tgm&6a zu#Ejt9_LqSO**$9(6-sy4g+P@Jb%n}G8z}$hy0|lTEc{|tV_)M*c8{@@e!vVB-R$f zCNyU0Hi%yLywEfl2yApZ%M@vDeygw0XoTqOnR`gHHW$tw)^OWNZ_Ewwo6uoN15|Qr z{w?FtKyFAk&NAfWQ|y}fM+1}T_ngkIL)mL*%-MfxG)dV7kHKw}dOJ70{v<)!2e z_KV)8U0b=4OJqqbW;JSBN{eAJM-UvSkyWmFQ7_yIi}+{>Fhr}~&N#j6T-MNXouGMz!PA$!|iGe{lWhaSG8 zFud{26WasW=xd{y`Q$J;YCd20F?4ZE8COX;!3JSm)%VSfG?`Xg55cR9%j0T0{GeV% z^r#+V;u(*5O+ML8d5jKQG0Fs}HYT+N?!mnMQa@dRX7ZQ+GC0g%P7( z$bA%ZvsMkUJyj`}08=+9HQKxgUqPEXuRQsD&wO*4Tr+^zaMxDws1LY)$_?<>`wl-u z?B(sq@H>cTIExKy#n!~C`OU<4T+G_q`g6O@QJ-hZnZM$glN= z1bPts#wVJ^<>Q$}1taGihcAk(&Z*u^g%3EjB_ZbHWd9fbwyO#Sz|@?>dh(SBst)?` zn7-^6bED5(qHg(=P8<^TU%$Oxd=^+0LG0sRM@z$X%5jI6Fmz31}{Y(-=}P*%9m>3>*x+yz#`@ z<8UTI>dQZv#U&MVJE8A0w~>JyPM&=Aq=q9G5IhX88#Mv=w=$lf`=b&l=m~X z%{3VMC=TLMeCWrTLr0k}LAGT;be=uPfT=MV(LaIYz{zHU%o|2;DR*(HZph?U_QNkj z#nT2GEWqi(uolmA{$Q8Udru78rdJ?d74V$bAagqD*X3iQ={3NtFa&`C`U}e+PUwXo%4>c7SWsV znKZ{JG=%RM?Q8okjXmoTd{S_aY-HaI*w}xxBewUBled?Za&L5P-Jj*kqLWMu7&kl| zPduAD__TSh-)Y581`n!jdqVgLSMEO?eo=2+c?IyDTdtcbHH_Qo*!=vM*Mgkd7Yx+< zuGhIf>BC-WdASs?*Q}Ct3;{PR2P{_Dr)_l9I4xHw0s?pyxPEP&>!Fbf3^RTD&?syO z-dQpn6CU_xZ#TCi7yo@aYyEGn-^Ltio#{5U=D^0@OE4;$3lRu#+~vanA%@1W&jPV= zu2E{7h!A945GS^6UMz3q$6y%N;LEv_?Zeahu?}Jxgb~Jl16rPY_A^esJM=rFb1Hic zsygsU0ipTq5&qne0j3UL3UL@Wg0c8D9B{V{>H@i=>$Ea@Q@68N9LkQU!IypG1C9(F zHBv3o9KSD7Tq0Z8Oy+dy1crD`jZ?B&1XidqB(n9i8>*yetWLo75A17OmW-knW0M-5 z6cAUTGM z9z5GubzK0$E7dT{vFdh!3i>br<7paHBav1~21s4-?sn)l&;g#F^9K$cz#i8PFu0V8 z?QIOBh=AI9V(_%Hv8G>*aZ&)htL9`I!l(O@OJYI-=OTId@Mb*CxWKFs9W1MX51`=O zz>VC5VI_(uO#5ud8&Rnu9!TVTP||qX7AJ1SK6CvAqoD}$2gZddr!z6m%AsPk3gf>^fA z9=f6wi7dE5R?giha6;t+C5}e2hIv=EFy8~Rak4}?k`x|}S8%B~WX(z>3x7?2Z$n~#+g z2(FHXFTHihTzg!>FnsQK^%n(Gz3S+S_jQMktHDfg1GvU1-an#s=!ORBlWw0%Q)6(i z;mnS!?e%oC*>*8s`$V&6t!J%Y&BkkSv)CjCqxFD^r~yX~1ZWe8f%R!jG3*7I${8j) z`?n?GXl^jSG?szqA4lB`bW$Gr8xBE?$%vWiQ4NVUzF`&x6+`3N=6WH{W0O93a0_lh-?{A@Q5KaB;hIx$Qc9`k?^yV~&Z9$T=JVOG z#mc!isKHM`NO3nBoJp#pYc<2^A(D;#b53qADQZ{+j{?T?j2C=8=v1}Uk{=*qQgmp% z;Tw$(iEu~PEoZxg$@R>_|8m>DvdmwKJ3KhhmKmUep^90l8jVPEzA!~%;A5g~6w?OX zs6ae!RQbw!9U!C*ozu1%O^ya%w7r*qnT5uP<}D|jb()Gtr9rAD7X&bb2edgU8&tP# zyif;@Gz08EJIgFauIGo^7$RjJ^hSp$wQ3lvNcor43>bVRE*q@X45?wyUsodqHj`M; zimseWDWjru>7xToy!>3akI|6jKjf}D%)ztbxaYH-?Sn18qDvrdM4iNtt@tXVc;{6B zK9p}io6+JeeZ`inBUd+XUTje6O<(p_8YdfU%mL2E*&k5e!d*W;(h-%CNCEcZ!OJyB zu~c*c+l&tZH_x@@#`hbYSS}CHJhnQbl+MI!mV1Q{K3E)4{ zdUjkITa3|NZ#oyvO_QS!JwevNyMK0qZo934mV!N-4<|(Up3-P`-Fj4uVrz!5#(ECv zvtrU2I~p6-VKH=gd~*WBU1Nh~1H}VQ6|a2tmzaEi>kTNNf!;HSu*dcF^#GpO7=iVN zG}3HnkSHh0;a0-{oo4UF?eDmly?@NwvbOV0<1h}?%{7`Ue+pRo5$Ffc3rb*EwB%KS z06hpq2z$&-Wq>*(dcayzbBn25zvl4c+{q1qJ0gY!hOL8>uOUx49MFbHzQ_newTZ66w73Jd2Nd|A@ib0wH#&lW z8+ap3a4XQ*049TFVoG7~2G#Yn<;Kyp<&m`41=~o5L<oIhd26t%d&9$l;!Y#CTM5Y~(g3lOT=v_dm0+4MBJ%nvloZ5WDAhInwgm*X| zV{ecxi16IxAlG5cjIdOFNSicQgmn}}0xr*JzJgANM0g~Mg$kO}gB^`%88?QaZ*gfL zca7oanC8@1Jh8>am)VGIBGKXFJ*k17T6SkaAmPvYVnAfGicwxbVmEGUd;o)8a(tNc z{NaFOkW5W{a6@!>s*y^C$y9c==WyZzHnzD@Lnd>LdZG`P=wkxyFav%PIM2JbtHrV* z6%c1La8u1=Z2q!3c)b$+||1u7{tkJ4#Dn{zd{4M)kbPd&74!XD^= zs@I9H$d+9NCOpOAc0jg06s#`awn+x{5@AgB`j3N+~-Ma5&k7miK7Y5vvFvi$G+Q9PwZsmWE39q!;Fc#7mhQ zlU5yWSJOG)yahYHy=#lr{_0`Z!>}oQ>*G;;GR$Pypg>Y!EmZJcef9Y%B1wuK^R;WWB{#bV`D2LEuMYFtpP&EyJQGZ$yN#0r<;#jK6bG8arT0#N0t+m^x(58)x-C11mRWrP&mDYL-ZAMn&$~#xloI=bfxgf0bmoll{Y>Wc4I8Kd=XH+?r-O4u;f`(4 zDnTO6v5Np4IvpIl1GF)WF{94_!0K>)pmoR|ua4Uyf3!9tg`dU5-g*{;o2|i*_q7al zl0w{2`i2FBhTNj{F69S2TMqLq+xe430bHmHHZ*q+x!Y_wun@kDjE1No zt}7u3&OV1{9?Cd4FM<6@oc!JvV*A)u1*HI>fxgYhvv0QV^?+3%naMOhD-JUu15E5b zbP^gi@1Bjv5@(+mEsX29p#J(wv^K8rk`=`;#D)Sxe?NU;__}}WC81w*M(-iYItxHY@-wGg{Q+P(vWes zwIMkn7qwd$ZI#|ISaACEa-k`j!0Et)o+gz03!99`q0gKx*e@bHleBbRLu8Jbo?R^%5KP7o!KGuSnx;|Q;*9<3}k@Q5gVb8 zJPdP3{E`p2z_&fPmI%7l+fEwH?TOkAnu}`pKO9jZcz+xx#PZ~~?ej$hCgbQr0S4wK zROujM)xxE`zjEO9LaBrNMX2kv6_fSWZ!tI51*^C?+<8brpwtp@gzMa>v0Z-Ct+CDE z6t1-*3fhg${~68gq5373KR&SsbAzaHBw<;xxgeLYs#`1l{yhsxYfh9+0Gk0G=GWEg zf4R~yGiZ3*S8%;S7DIzw)mp*A^prq_@L`cn#i}@xR}9yc7QyA%Uf%c@ENzY`(em8i z5Cg$p^quwvjNPU|zH=}j zEu%0oP~ z)6PQ?&35tG?j!mhwR4*^A2K^^&x?k2N{|hrj)7=CneVt?fN%FuetCqa8M#m25tuI- zpZU6!>S#j?I;fx>>@d8peu?@nM!>Z3`IqUV?K9e;m_s6r=yhW{M-6D8y|{P7hz^Te z&f?hVs+pr`c>CVTKxnvnaXgA#FmlNH6dkfPjAEOkSHgW*WC%X+Z$6y740W^h_!{$> z_2M7N8i%2%)3OJ6DtN5pd4N^Y^mGsX2S0ijsJ`Uu*FNi7Z{9tpm2KVDS=Y&f6duPC z*uHwJH}~2p_(W*|qSs0S?Il-xalNnqZpD==*}3Z;J*nR^4zBZiE8zFW697X%-P2Pk z$CV=^!>s3cnSk4B*<^kkx7UUsYt`y8T#(zizX<&57Q0Eb(zx=U2HJ-o)Ip~m5XM1FLJKutBFO}Lf)_!-< z-L5g`I&P1<>)-C$c5k2IF43*OTqN1GvA z?Z;OlXl(h87Uw%TGIV^XgQU)H`bC=?Fc|bSr9`c4BJh!I^O6Bv!Wpb)`?%nOdM*UN z{%KBN0)veP*lwmd%zdFb7#fl+ngwsj!yP);nS$I|1DJ?<#AcIU^E4Btb%3zkMAW!3 zI@|#zHpJU)qvki1$}}5TC*JAd>~+Y(&ho^^eryuJ(;WM0Ms)~c5E(dzkWr?F_BGpF zz*c&36VYXOfKknL=w|#S5uR-y=xgNQNB}UZs=vD7dX~|$8iV%vR;A=eP>T;|9Ix-3 zL&BBHjtbs~@7=wsB#q(aso&sa>DjZ$2PxOvh{DrPRT9Aee@!0M4 zSB)!<31Hot*ZiB2M?b~I5O;YW?1q0|bM4gK=kShsVzY6{WsDamYycsL{eBqsfB^LN z4)|Cg2Uv}FGauD>Vi)5ESV&1KX>6}U zk|fB&>NBEzhhj&8iL%ngD!GqC(B}0lwRv^^Xb`q{zW%%-2qUhq6%Xv7tQd3j{U6+? zQzu}9YhqeMAs8FkSXXtL_M|9<@*>@4)W(NGomlm8yz$N-$zcUu@Mq|ux_|yZjkfB0u!H<5<_^U@BvAjqe5fT9FwAFZgODD zfxGOwmU^WYqGc}dV5Pz;CSG*j(RJruifB{Acx4Q?n%4VPF-Mf%JZqm0Lu#xtuQZrxMmwbit$zsCH`i8dTv{j zwr-m_`(u%Y{|-~oBhF+?;P4j$W#ei6*Y`=YvUji2MP#}Q?|6hnDq{w(I0U;NI<`lvyicClRx0UZR};s$r9g2xzu4!7F&<3AL6%(iNQ>I z2-!7bj5SBPU2XMcYlaMAz-5j?8be;!gW434NZKxKBimTtyp_y@W{`WUv-5bJ7loJ! zO1(#O?GjU9O6nE`BXbL-~kE~ZhW21q=Oqueb%vaNY7)%rI<#gM`BWEv~4Il`^+6jXL5_vf$prnpoS zQBqPwLmeh`YOYGQCJtOSbnrNd5}J*I?Y0{F@}v{I@agihdFJFg$q7box0EU6TaU<% z{NVFT{dbW9a#OSp>ZEDo;HO;w9YXkB{rzQI3uN>Vtj@RHQT>&vPA@_Yx!c*E4>nT> zJ6k}Qg9r=bDy0_ka2KE?Y@m~09aNW?qN8PXm|KuGa`$((=xbA^KBoJV;b6Yr*V)v; ztrS&Iup2`@w7kkBI-GAR!-u_Uty>Jv-olJhrIT3Kz$NM2OO;|F>3QpyMShHJeaf76 zs4-H~2YulSX%ZL{2^t(_1oP|Wo0~;=cE!p)_HcA>ZM%K%DDX7wg%dNKm_;cTYxwIZ z*9V}YVe_rB4)o&nakdswgdxBNj2P!03|%v=Irpv&8z99tqASYq{+Q~((I$=Aw+OLd zFc3S4W!!R!s)hKporuV?q+D>P7i&Q9;d|xYl&3a|ksi5Ha*uQ#w2V@TiF}@|%Io4X zeJM!oUE{`(^PQ%n@kCSTp#PY0XflvgJhHKchEn=HWW(5AVej1Q$cyvy^7RUPAB3Om zP3CMn*((RIYM`G#lt))}slQ*54%wsBL490*d1>ncap1u>DXSdD7K(7&q0UvWYwhLk zStyNOaamT7ySBMfSku;X8PuI8?mY46MrkP7l)C5iizTbqTs@m8QB)UR{w3e8ZOJV| zPSwai-eIJ?cW)hTeljYt;!&NH%TJucHtCbC#0tHaRwgQe;T_!Yd}t*Di^cjA3!0-n zc-ySM1s299n#^c6oiY`Sc5YVlX5GcCrM-`J%YiAUD_J|P7~0A?LaOeOC-$IxI9Z0W zTfO#n_!q)(#Q*_5`AXP3_#_a7$w=h-BqUvc@2Yai6JhT{Cx@tWs&DxM>kz7ZU9d^R zX}WOvnxQ`9P<^1&+bQzCS;y=cWHas$^k|sOf(Zt8IDmFxP$>k$PS}$gty>1Y2{Cli zGu4nJiOs6JEH@OkD%&*&A}e0evkj}$!e%^{RSOJk?;c;}#!&bg>s8$;m3w32HG7%l z3J#~n%|YuylvQ_5jinKhs7mSrFIN!bX($iiTMf_ZizJ6JVFEqYCrNFt48;s&6j>H5 zQp{pPOsJ8CPV0EbY3&vL^<~woBYbMGiC)E{wiXAFPLyea6}H-sK^D{*N8s<~5^BYk>S^oNW&R zZ)qrSou&_8$ph&bKlPQj*>;9R=*z%iT$OPF&6z^l^_U1!Ch^j9#gkDEn=lIH%tP&MVWgMu*RYH@Etj7JYg^FiLlO?%0ehtSq= z0G1YE=ws~4vM&*IcB!>EEq&znEBAdSatpUcnf*cFV{P3GcfFcfe?Ykg@Zi-?S*w?? zFJ1T6V`lt7Dbzl37s;bI(t^y_CVyQInoXbw77k5{;Y;?O15gri+cqt0uf8E~S>93l zS0x4?6`hDlA6*y}?47}sYmXi+;*{I0qff}{Vi&7#4d;nM-CnWXx&0j%(&;a zV*RwNFgZFMeQLr|TIMALsP+Q{=q(;Cm@cNDJlN|&fC^6+IuYTHzPRc_sJT%!mp0HB zr|CP4ii0>zP!z*7hv1A2U-nbu(F4cz6;2jL)!bN(u>9Vl&*i}QHo>SN+NG4;YGiEv88{NUYRc&-xd8s`^4LOH*@fr4UJ@LYVtEQQkULnEFzV8&kT&`k(_)6!cSP zlgJxdnb?6XeMJ2Yp%z%S;A5|6=7MWfiYnQ!@_YseyuriKvgQnkfk6ig<(d_+W#$J9eyqROuAwcL$^r`9z|YT57fE{}iDWBt$S+7S!rWCF=A zMuNf6uZ|F_CiUr4fZG9-`hghLYnzHN3vR9At5S(Akv`H36Wday3NzI{RXxaV=|HDA zM)@aJ7%6YEAJLeoRH9lh19A1}-cPP&UtBRlckeeiFYzepSS>0oR8)K;?RluaW$nre zWG6iy6Bi%pR{Qju&q;k$k{_B|3yUwuPU_IX&p&Q>0S$y+@o9<^imEk1ENt-HOi>~gLLNx(KDMtGHE6Ti((^-`vkraJeCnb!Y%Vc%X@ap^d7yu#gQn;+``vQ2G%^S#W) z?)j(QS8w8Xrn{-4bG@`lKEL&uWuJAMEGNr{OUfJNy{-M*5QaVA`&s`GA~vHU>O09r zpG_AIT<>}Qa&_gHZbaSL|KYx-8so)9?#z!b0c}G}L7O#$K$VP(qs@j_43X&Ziz8Kk zlrZu<#hmx`V89*I=3I>}4Kb>1zUY`(G65rP`)5Brxp(g;5|#&NId|v1d{hKvQ$TG8 zbF*>w*_i6d!J1!Qy%zv%GeD1av3*bCsA$OLWmKx;wL{va#i_JnL#3INu#BO)y{Zi7+|ZWm8;tZ{D# z_)vg)V61~OTnUiyz}X!35^PWcDP4dYgCqV020TX@@D!4T2_lV2pC=6)Jw&1Hiy=u@ z5MMmRw7(f)tzk#A_Nq^GQS?xc*xP_HD5p8Wxnyq*Em^i1j2V)=?VDQB>e(-CKzxIt zT3Gly8rT&*hhNqj+9AFeelrB4QdC1yZU`GN$(`j8P1}?bz$ZpxFL|q_Bog5TFV%F`O~vKK{$+kc_yV*Szdd%QBF zcc)sgk|pgX94sPA5BW_NZ2#~4*~P5Rj_&l*?`Y02SI9?;N&VZ}qTjqTWR! zJ8P?pOw$v#vnb$H7Y9AH_1N3LLX->!cY2CqbYd=)J%bcF<=Nm$!zgbDSJ+Aod&V&X zfS>*^%FR&{tGDT#xLV5AHBV*rSlM}fy7v*ztUjkBFt;gf?R`73pfN|Oj3SKJ5mPZq zkm?(ret9E08pG%;S|B=j+kfMu`!&Eo;~8-iCyS3!a;5QVV`9rD^Y-(~`SRhnE)R}( zo!VmNjBVuT{dz6?efQpR2h|>?*aG7KTZy-6_2Awy4Ym0dBnmDXr=X9M;T4kXLnpa9 zb}~ZLLP7`nz^c-t{qaGkl{y-7wMg}h z4t4G_I#jX%Fv<4c(bq9bC4cFH6bxMQMby5XC6(nQKrcn)Ep4-~yxAlcBinVTJObBu zed}qR&1QM~H`>iC5FOciHG2Q1o#=!ouR4!I43+glpNlVf!kpwox7#3d!1b978-lY% zJ{s3jH$fU3*NEQcBcHarOURAm<41BPTM7eIg}oJB+=u?eLa)q)bC<%@f!XP$*WN~( z1r3LCDIuh-vFaKx?MnUT`s3Wlrgz#!>ewG3N|B2=Z^wppvPVOE^dY|H5!mF0zlEXo zGqzH2-8{osL#f_;;mo|I+R0ZuDBmH64RP2kVEx&Novq(i-s`ZtpI2Ai@63I*`{?>!wzqP8 zw6B6K%9kCPUS+k4q0Z5vVV#sG$wr}Vy^)n2b-y}ZOQZk(;;J=B5KIC=Zr)l#u*q_e?Ivy+F5aP=mK!G*UfLBXx$oD&^CzNuOloq#FO`zph*l zN{dCM!~T5Ut#%*1F}Q0_b~KHep<&szGC5g@kS&zeW(8cB-;#qlsy5 zZc}(r-`M(~8&DxPzmWxNU%2_&>$ZKtIvYN-&_%u-cdFIu zaH>iad8?ni(OR`}JRmxPf{t*<(}E&Z8uSQNe(mmT9$4>rpeXO28ZX_So0U((Yu)Ew zF)$tPlIJfUXKLI?5Ro+y6K+86uHl8v+`biiWy z4UXj*f;kXkh1HApIJN?7OlK}$o$pacEV#QzoHC+E{O2uUx7;e z11S}o8z%+*%1(>8#WdV4Y$;i`o5{rofKhsn-s}2JaaHYH{OBq|a_IwA)plYyP3P(+ zF6qmmMmR1tC5N`w!HB-Y{n&=G$Q60R>jBZg&TP}_<|#uILF7GucQ&!Bndy)S5o#<- zrdxC5{fJ!Kgm9{qp{R8ch|SYVmkGmYGpP0zg{>$3Z~G}Qqwzpv-N?`@83k~Pk-EM0 zJ9J@}9-!PPlnHG&|8DX{!Y=KX*X!1hZqviSm&Ttz)P1XK9Dn2;_D&u0&dx^p8e^H? zki<>5Vul{kuxOCHwO7)a8&{qdQKk93edziR+-v1Eo$OIjbpJ^)uW82jyhTx}Gr>q_ zlim>Vk(_wsy_*(Zyf8o%=5&t*;{r=8xHw%hN-G_g%Vi&m)?)cK445hfqG1qX3Z0!0`_hF+(IO=UP?hD^lduU^2m!)42k+V~; z*z0aG7&oHorb1jgxV%n0qzyApd9$R$Rr_U)T}Qsd>O|?AmF%Roojyu=-_g5Exzbae~6jqM?KA}WZ(NXhs(7<>9#pS4%H4VCTbxY99_ zd)XaNE<`67m6@M%4u7!D?ytxCquKARSiQLvR*ZEmfL7D0~3@b8Vkr$y`~D#Z~s*hK=KEsRO(gVP&}P%o=W|-SsJt7Q?tlKkeB!d_vO% zP_7W)z;HqOva!#df6T@3;x_OV)9v&TeknPtG}DAn(NvoIDelE3G$I1*vprp(E*`;m zs8KXd*hU8ENqdpG#RG!-*cEYf|GFWqSZ7U6PDXmskMV^YT~gX>=!v7bjT6>046IZS znFc8aMf;AUJtgkw!E{HE%f7<6Vyowe2SaCYuBnxsc=b)CcQfH13SA2G1+>e^8-|1n ze_T!%IF}@0yt&#*dpl()t`*d&6P}3E&OW<7QBqL4iIcd|IVUH+>0WDKn&618U|5HF z)@_}A#ktHdvZ4?Anw~}&jR}gWXYCyy8u}8%iqrJbut%^$VXm-uJe}N~AWj-H6L;98 zW93pDEe+_zASy~~HtZ#Ly?a+Ywn;-4*=pVI$~kgDzNBq#w(D4x1mRwKa2d4g$QQIq z$d!Bh68F1K5#u3wgUkoqI(XlvvTo_XNP#T}^|rAq51LHI0ua;yHw;b5B#9y-;sSh~ z!EL<>XeLqhhMcu$o*PKZmC~=*u#T?}trs+@%2RHulD^^Jh(i%+Q8&){3>VvjqwMhWkgTn2sGBN$SwJ=GhJ{ z0TLSZ;qTB~bjjqd);-s3%K#d8nsD_ow!hqUu- zQ_~s~i=vvl-_yaa6)o$e7JDPa+KIo%()UQi>`m$nX!=vmn2t+Z+HacspHLz$N>0c!s%Fv7tKtVfC zp0U0;xw238xubAYbkxFs{%yHW_o@U37|OJRREvC<8iRJ&jt&Y5|3lWA^_puxx9pl( zcV*Hwc;@eKec^uH;fFbR;+_40F~ZnCHusM?{OT}gJr)hU(wHfRcCBGg)=#p2#Cqo4 z)|Qq$h^j+8`fk6v?IkoG#WIDf)ZU{-S>{Y$%!U1NMTqg}|a`hZz^Y1K@AgkK}}fX-y2^ z`ak#(CVBrxpbUKM(Snt^1=Dq=pEMPU1TyrTzUxUZI709X#2w1;arVMaV_NKA=P`!E zFxEAlXrpNP*f?)r&E^ix7xwF*aK{M~mj}2DjYyD4V*wk4?{u^}cBhXG>|oSrr^9e8 z4Ul4Q7>2@#Y;OwA4eUM^xf6!GZ%B|?k(u;$*h~7@{siYfny^-}_q)B?nrjr=t0V;C z^kA7>I7|XKFu_RCk;U834qc8~BL&nAw8n{XhTi!rT;bZ3`+O zGnIoyF4HO!#b8?t_7Sq_GZ&f6-j450=I~t+<(LRV=lXAM^x1ueI4MoP8w+o=>JSD{ zva6&(f-sNSwhl&Q2asmN4*>8%2nd(ucr}(Kgk+h7T}L0I&6k;*{TA4;$NG+!8-#Vw z$2&F>y|#gA$A)3p5NH1j*?WW#XoUCjLFKPLi*!tL3tRN1X2a;?++yz~>8?FK>iBb> zIyHVfs0X8dD3>ASC+GsUKyU#Uqa*^13wC20bH*pG5^C6X`29C*8wG4bXc^mFI#3(` z2orqW%?X~8q_}>THf2+4c~tg3Dz>{+Y;k z{O7O2RXJig*QhVSD+YOk-Ob=jaKNt_M1)trjeOx62Z6d}3(al8w3ce@4>5)3>0L+pH=^XcT=z2%fglTyI%+;?beOr)O>Dvrp7R-RP z{{hw?>-lx(WQ<`(h)UCauQ}@nyd{XuSa`z2gVv9Hg+(w9y&Aa=4cPA9*IUg@A&~(G z&w%aOiy0>{?|=ovfc>^VlkgpM@Wv~v&$2bncu&x6 z>UaTjh1OH=cigqr!^Nzn0R@j1YoYJ^gslcY3~J1pLfntM5NV?Qw*$xU-sY42LtWAx0sV%YS4CUmfW3u~^wMAUZn-v2nGN}4MM)Jwym2^-_uL^CC6C-8Lb`}6T_Nh}@@0o?$k$^S^F%>z((;l>z_NyzsZ$0+ zEGCF(GO5Ha#M^+Dm};m^%+r~_a&Rv`|4Bo0iIMt@^LYQeST%m4uA3Y`oV;yR4#-T( z`U<|>a*mT*Oa*Wb?IY16PuutsJK|ntO9j^6dURy&NFTX3QuG-{fb9koWi#>N)&9Pc zrta667muO8W%efgI@r+Cts z($3T#?vJCN73q-6n45grWB57;?_g5u*)a8P+PzLrJ}~)Z*AOa%9wPy99!vi%Sg+g4 z`q-WTs;rag=^^WrYBoNO8%p(;8Kog@v)3{TF;Q5a&*x!U!|c1>_TZxk%jL2KRfR>q zMBcjO7k{)_w}uD9v{HWXrVm_x>K)G=6YgVs@qu`i`+dzJRAsthNdt<_-ty5CJj!!h zoYboXt&+_h+^>F7`?7rGj-C#f+iI1-eMP>DeaRy93?UN=l>1McnGl1UI2rBzjM>23Z(L*326%VyH-l_t-&V{xxcbd7m6}YGjdox34hw}tT1v#)NZnATV82^? z>$4E7s?p9yt&S98^2QU|d%ZDgV4^x2^rC&s3ap=+^<~036~K~fLiFx>aAbXBec@L^ zwSwVf)}xu5zUzAwLsSJsMC9Ex3!LQ0P?i0CJor0)TTf2Q%{C&UG&V=rH(o;C(;`ei zap;ONJ+5o*YrJjmzT8EUwq3ezj=?Gw@5Z;0FSjALx@7JahjO`e?X$?2_Cgfxx;7DE znG^MZy&d_5eZW{Z$zaCMO)RSF-v2y%i(KKN=uqryw7RX(eYRbKN7QbxdpNyZ#Cc-v z^=<5Ln7_9-2)}P}ug)2n2SSLzE@FatMUFoN+f*Pp%!L?A-*qpsj4iy>=Unf1d?SJe zFyIaf;XXt8hEwr8*dp{TY4aRBT%vt9@)o!2Hp@foUJ9&<|84tVedvU>@Q(72--)mEtdmW+|wq&s>)CrtD2RWV4R({5>i&m%Z{+)*j(v6 z3S)iqT|XDrmw;@;%621DeuMkdoBN~5h2_^ef$Dw1%iQ`=?oaLqb)MEPTToedS;vp# zPyUgAr+A^YZ&XR=e(K}h6i&=qf8dcd;;ftwePR~ zXLUxYeCm9oaX{CEG_)7Q_hE5&9j4vJodv)GI2s~(1Piztu7-6Y49+g*;C(xvO045u zBSojO;OWC+Ee*He=8Ze6un*nW*y;NxXq;FqV2XZ(1txf?$77_SxqTWy3ebYiM2);3 z>q;G@#`ac+dH--2TI8*VtquioLLJ+Bhl1<-a*3EI1etPF%&uhzo4D6M7%=^>#LpU9rhthn(?&dtU$De-8&Q z);6;*uxmQhzck9+2KNeFQMHd0-NWo9DRuUQy_0G#{k7m1p*Fy4Da1bI)!|*6~R`K1SMy)?+<5$4RFH_*WxNhwaTvE zmV5rEr%p#fu*(SaGQ>vUJ1z@>LDhcsYjM8i%;hVOmIkMI%G4)jN^8G*d#?|r-)6Gj zn&htSjE*sPTD`G#ncyLWuvUAGC%7{u#i}x)YP6rN5qUG9!Lk_(;2Nh39x@Bz~IP^-T*vtx^r}Nb=EK!vl@GGv$+NtiLD_{nE2ClK@Nrm7A5B@T#DRoDa1?~PGlL3f1BlV792Q?c zRxm+1qjCxc-%IXO$=Y09LFTq$(BSwAd&O*8XDSxjt5`;O#f0N!hY}s@kxn)d1cCJo zuwr?2a-Xam6x#H}Lm%;cfj=iWwe zMt09l51OPv7~|wSE^^=jwhWPOxEd=iS&fMvID6|H{?bWZnCXg9>sfF+?)?3NU1A&` z0<1LDA#9gLzO$Qo*$ES{DR5g8fNL5xgpzfWuHEEzS%orM+`5ar&Ek$<*|~RmdZVS@ z?ah@YxRKMOi88mb*U7mNBI_n`uF@xgz3^p)n<0sX*WFU7T+l!?U7a5`Dx8>j!~xsN zk!V|Fk-~(T+Rah(_tu|3`WV$wwVy4ey~{H>kxFc!(|8K?t{P{MSh|h5tU>p=eL)7Z z?>j#JIXjgxKQ|v$0yLIb9n1$|nB#fVdN|>W116V7RgFh7iUJ+jAL}+ZZ5jbWY*af;}W-Ib>;9%hw3l5Be$bi;}QRdb^ zV%z~-EeWeBw7Jl)58oJ@@^WnI^3MXS^VPF1ub!V4$A*z{1OV{6k!GRmoD>{k9UtzM zuyPnoIa|}>Jwu6z+w$H`an`w#_Uhv-K`fuu8~fSQ>n;Hh-=F6R^~C*Sg|{ z=&*QTG{RSOvTbL=_U{#S`KU!hP{Mln~9nCKt^7dbeD^V_9N# z5D}frM_7AJA0l7(184y5j5~k}Nl{#6mTox291HC-C!*=c7M2*~3<1R~BijbDY@~ri zfmn9e;2{>UWov_SRlegxN#X>jgt(b_y@EL{?T8}uN4IXHu!Al+>tee>clRx6>Vfy?LwhfOi=BM`o)_oUPEQa|5qd?btM+YBG;ue(q<-+mW zVSjJ9IDiGyh*)BA1}FfBt~6FJz$rocyxf5?I$5veb>u9F(_a^Gg2APa_$o23bD^_g zTL`l>{NN*+T14aG2h;}!>2&u4NH>QrS_y=e;}OJ}aa)ALt=)-FrKnopHN6+GT;;^a zCxMt()5*ZYjKd{?7#vMxrz&3`DOC@rup=Gy|qrQ%*i+a_*8pVW1`NyViSk#-2<6?JAR#i2R_t0WS2VVRY(k6XG%-@RUd@k8vt&=fqqzYV-Qm{?pgsTwEMO7ZawQnH`N? zYnnRPJLbP&FKv+thfvrnN$R29n>Ez)&^A(>8b1|c^d32=*X1bS55loes5irv@G2G8 zfW&`54Li~e)Eq@NmOmbt5RgYSyo=D|I>;b@jOa|)TvbDDD_2DLWh&P=fdMg_+tue) z+!Yfhb5X=c!kE21p9Nl@AvGOQ_xu>CYYua7RKy`FCI`#&Fk8sHDIQ^<0|1cjgO@`n zr9KCc8Bg8P@E>&6QA*-LZzXR3+O@YWe?K7)AOb7lfKxc-;G3uE2QoYY5Ou?V1Rm^| zotopt#rz@mk5}XEkv{+~%-5vb7zbG?73l^4sz%UV1Ad9+ojYp_uk_>I-ZV@Xk3P{? zQoRe)nGT}pjJsH*U0``y_DdEt@9xN`e>Z4)muy;=-=x zJPsA;eP*NSI#o12yD5-7Mn;fAQ#nT7B7jAui?V59)7JJ9^BMIuBsTHhD3cpCvup`` z?i@UPg}+1TRE!k;W!wc+s*IbsLwd-<`IOx z>VZ#0r1|THC;WMbbJ*mHzyKjusOyK$l&GC|bIZnj6gke3>q0#rp5;O?Hz|O~MK^5U zYXC!RNh#JGJ9rvNCmA_)=^qhXDGZKEX{u4%w2%@SQ_l9(3ejU5a}h=6V*L&mR1k*y z10_4L5czG6hhg}!P%O|gr1PEcB)Vi`+c?inWWT8vxRDOxYa^V*NCfQ8VNSSBWNVj` zIk9dPZGB9yHW?33@3Z3(q_l0H6t@%_xM0)=na~h(J7KZ4SKJhlIPGyX(p-T(k=u}b zDNkG+r3dJwZHK-bL_9G+j8#uALF$7+E6C=Gp*X~HIq-0<<*xsgoYL)@HR zmv0SnN1~O#XH^K(hRJ)PmW(1g)a!l}cgJ=`YL{P#@}xZ*x@hN#lQy}wm)t0*?zSIg2(mp|+dbPmQ8E~l z*jCZIs*z)gF(=m&<$~)3D*Y9a&s?g7e8k=cs{ z=8j&(FW$4v@9pp97y1D&?8Pas#u^InhYS4%Xmz9Rbkky27xSAX?0_SYp4oZI53{B3 zYjhXJH^6t~atTlL`>Tw_PZVIC1q-q@I1y$~G~$AhKwD!k#RO(VLUzL-Qw8$)@!2s8 zX>m607|mQ9mEvrR_QjLO$42VQlpH2uSN9L=0Ri3srA{f>4srZ?PMeKx<%U5|1a~~@ zWA3}Xr=mu&5#_tAg@QSM^t3whZ~*TUyKl{+sOMU)qc$Ey?RsXb$gROtl)XSh0MSJ) z!hl0<*&8uJE`WYT>Z7H(1|qp{lk|lwq%7E;vfJyx0u$S0y0pMXC#lxSG3(`kIg3Cy zIwvsI>NeY)fvUeV&<8~}ImSBXVeyOAkts5$VB8^$yYutfdCLZhS44GZFV+*WwHEHE zGxJ02ggm-Y7q_(vWODAI8tQP(i-;1%^Eqr~V0wBkPWUr7XiPZ#9=k%9@J8>!2+8ynEc0)K+d8S(IM0_rUQ-hWw5> z5t@2}(5=0D@~T~nE(y)dM2^w-nOgDo>-!?Qz*+B%sK%l9mX&qmJUAxh2;Xl>nDZ)n zA6J^Y4zATByy9>~Sm!pSbg+(lzLPMVg6}&%pF!SUjXPciy5nnF){cChLAg;Lf%O32 z^9eg*?;lKw_f$t~+9R;Rky%lfi!-=tXBWX*T4VQkE%DrwEYdcP{(n2(JYU8ksi}J~ zd}Y5IC?C;?gK0Pg&y#WLU|=wC1(f7&sKiFG;bUS`_}#pN&{Q9C{>q(fOy$a@6MPM4 zX^b{AwnGUOfB#I{=Y7P1ogLQ6#AJ-T_HYJ$lDa-39h7xosh-w+b@B;YxUm`|+U;a^ zeEdgnTfaHiaiM9sd9y68MnS?kt<#1r4>)Uy=YkKL@|dbta~1PAePbiM8Cg-<&EC#IsD4D~njM z8^&e>hT(QJXF+4-pF8WZj+0HeV8gQy2KVb@eS;7Z@+5QoCIr{b6P!T+V1&i`ToIJz z!Xq4FDis$lw{!67?svlitGor3EmGxr(2K5r*HW-a@B8{;Z^n%%rQx9=q?b_+^ z>F}$Y^Q_WN7L7nM@Te;5m3}dNg?2Rk;d-0BpCgJQHpVDNQ_;(Nr|r4Ns6@d?{|PGV z=%Pgmc`bg5YX>I=wUtv=;s~Y$*%oNH`KF1>IuWq$G3VX5!*$L&2BZ#WX5(=B-G^># z@0|-n`$*l2QLVf)qRSu5Yx)qEhDYA46T*$P)gt!3(S;)K%&nyLfNb)pcJ>uaJ`V5b30gFEv{obm= zmT^`#;v9`EJ!X>UAwqi3-7pZaV%{@8NM5mjb}Ro zs6yfuft)<_C-l7gAanN@^^UXqcqcLGW1DiZ-?kCO)3aj3^ z#7A?SLt|sw27P~1j-^f8gE84ump_h#&4d#hX2Y4nx_Z4kqSKsA7>&S?$B4-FzO6ea zpLt$Z2iDREt%?1Tt>t@xUy3>uf2A0bRfX=hPIE8b-Oa_fGU5E_rglM2?Yu=_r2s6& zPfb{bHgl6$TCC`GsX8&pVwHPHxb8r%u4ScyNJ|akBn&pN5ubxv1b8NmVqXs6woaW) zZ22fyUa@H?#!c*%C~7z>fKqz}C!<%j0CCYXHS9HTfL!r(N}DzJfButU&yi2MPHI79 zy+R}sx1;c#eAI~v@fD0O@=J@J*^TpA*ySXmKeM4=~ zlgrb{8EmhO1?~*JU|mnm;KcHBR$S7zjT;lDuIXY?G=PjdZ@f^6}8OR+?RB2%v6r2R5q(0GKyzw00JbZM-BMkoV3#A*T{`}q3f~TK%xp8$- zuzg^xa}UCD%!>w{Q>M4Zd1t=28o&bh6W>1=`bFckxA%xmat=ghBwFxay4DWotoHMKqT;q)mAqs+ujI@y9&uZAFx9v-XgRZI{e zX`RLl5JwzxV~AoH{*D<*&k1V`;SJ0=QDz_)=2YqZZ_|Z$gv`yMzvL2KeKb)YdclF^ zf@!MtIb&Dve0GOV_DtZq;f2cl2OPuKo{RWIktgP(oo834TPzndfpdOix8~4*DlPU{ z?^&NG#G4C}<{H>%C$t>g!%ncAjYPT-07q%`_w4C+j6va7!;m*MUs{yVIb}*k?%ss3 zcwO+tA&M3>5YIgB_*g!hPAviMOu~0 z9r?Tn2q+Dy!?`Xir}i}*)DS*aJ+Y-xuj;~9{1psKCtN3x95#I7TGki}G-qxO5U03>$GNCFM?VQnoJzyzQ;yF) zlK~Eq;D(X5kb%oMWi-b)cp%bN-b2~(52>xy^cZ1f4Gf#zbBwiz(sFh#UGR_|^D8^i zYYj{YA1P6)0N9liBT>GU>2%rpUzcDj zw!~aLxOrQyZkCy3n}q4qg1Mr7;J%3`UKu=bG0u4zoIni6BO8gA=9s0v#bb*%Z;S4~ zah;TKjO>ZBHOF^O#K8WNiwmFu8{EM$tzWtG~CDdUKpajrMlswUg{_|H)w zZ}>bC&6gtqTDC{hULC6wj|_VZ4{OP*p06_NAwd6$7|dVEs(Eaz##hLUWl&wO3q&)y zjBPJ21wb``jcjSA0PF2s$B{L7POC7-2F^LxN0e^y4~~d`+3f=&XMFrQ9vv8?=I9GY z2FvAozmZ{a7D8E{UBqve-A%Oc&+%lhx%NKa@9RNxQF>=#b+dKC%5iT$TS9Zbet$3Y z*Tcv542w5$id{IommwUZ6&uBZ*cRZN`n0`w%_TGtgUTvNn}dVAm=Un=(1QbhbZL~1 z5C^QHq?uOs(S@jARlJOZlC+iUd!F8zgcZ*-;PU`m-zle|ZnS@Q&9fIo&^-=86wRgP>j8eiccsrs6wzXRe z>$qXF(54I(&Ku)^N)@@f-HL7QoCmIuEsm_aNfr!uiRMphWY-y1=&WfI)1aDyHJg+Tt@_qnic@ zB9+|z@TQ%wLhFde)Z=MA0C(9X#wnKdCECQmv$e9yl_uKd?eUqnfAm#rqK~!9d!6vo zn6n0pG{%N)Qujgu+AThJ)9{^zeGv6^iqOt8c8gqo_(EKx>S^6krdWTHI_yym! zbrG;KYmTkNijn(3exjM$d@(5pg+{dK?KHLHmALcVERPQpAf;yEhd%~_7Gfnkmj`Ku zgIn)_QRh5e+@v-{mH1pGnv(E7*e1n4A)-#~YRxFdO=C7`Nr4Wr;y0X5OIK{7IS-Hn z;*3EUI_c9k8rpHS?`@yZ0`V5Je`BYIqM>_lE|@iE<~Tq#ajZt1?SC+8+iT9VRbfB1 zVsD{a*|pNKPa5xKM?2dk(>$XM|GfN!iryW{QnOTd4bKVgvz8pGPxWa$6n!Nf1P^b! zY$1lDf#q*q1A9E8LPI)XhEF$?5@5v6;WJlvu`yw0$AB?W|1L72_$qrWkK-V+VtF+@?R{lv3m0GlJ;b2ho zro<$1lZ+X!l=bD@*C-9K5YwTKcWl#P<+qq?!P+3HrL@p~=`_&-Ge7_5$IaSfumJ>5 zqiW-+k;V648^+O)>rQD03w6W}d%x#15A(wWCjm2yxs5TgPjB0`hb_-#ryc;%f0^bq zJH|0~4|S-bvHV?a`7-eIcWUbtd7FO4Qhk8D^iWynkc#ZMo&6g4S(ZHRK0B7Yc7bu8 zU9;0ZYxK3f=RJ5!lL{G)%P0n^PNZ2HhDc|Ei(N9OkK)5F2qu`@O2-B2h_2P4BlG2q z(|x&lOLHSpnt|&y@X`a1&gz8d9HF6LdI2x%wvNwNv#c!Cq@fZ+2X>bH2oN$lvi8K` zQms6Q4OK0fJCyqtN~OxPAGTsoU~UOuomw)pIR1yXKIU6KEUi|Pl@`3Y@Q^F9F1jy0 zn=!1Nsyo?#(cYO^Itn!OH4nWz6;UxgYAlUjdgV(Z=HKn;PSJS|iWe}(~%C!hD>eGv^Xf)TVJmiJ+?C;`41 zrj4dC0_x&+5nq7@*D(FCq^N_#zv}=-;?nF`VC9&ZNbm#lP4l#aQ)AkkmFtI}ImJ)O z?lf$qk+$tq%YLCSyhMv8UhPvf=8w+*7Pq_hDyNx@bhU@T9?xJtb3Cy>`F0IdcUExQ zmp=>fHOqTd#E9+1c}f;`t(yq3*zLuPtSbIi9gl1iz`dGdvtbcZV^M~(28clRq6N|4 zf)jeHR>ot(2g30kh}=3t7mtkjzS#q=M4AIuoQxcKahOXJ1VnO7db-f(c7`GIV+10$jnJNU!ZV3Pu^Ch^^5demYe7SV*rOwdM_EOZaoHtTUR>TupGQa)S}1m&mzV*2xoIfz=h9z+sdQh9|XT z&k@zH>R@M_In9i+i9wY*w;$ZBMkb42APPjz{FGamt&Yh87)u~>wjj%lW4>Z3QEO97 zV;x~5=JV~CU0WfsiV5Lr4CyekKEoueavN-mRC^ut-c{)Ob*_L&tAwdPUy$h3m)6TH z2Dbft*mJ9`tEd5CS~M`xeOLGMX!CY}dms*884qsQ638!u8N0N7$Ai-9?{{>Gi+ksY zuRj0X+R2b@_FM#DQ!`8#08)r;fIay5J`p)ULJf0}O93JZi4g`_g%(d;iaK}a-^AxwwGfCGUu6igHIcPP3Ao`<~!IPO0O04n%) zZ{yKMOi6zNk%z@ukmw<^<~DPf_dttuIKX`drbMN@2@|ik201qP4wsfLDo(?nuvOy| z(^?pWYo~7mEONdE=pUVH-b2>3VDha~c=X((FFQIM!qEPVI^wpA1=9{VE`E3WrZ!o8 zo=3d6ZkEhjCs88stSy{Qff%OYV6hlN8vMO#@f^VL8J+=_wN0B&KYz4CVXivA{^k3h zN7raW%2?Ss`z~V5D_5$(koTbX#EA8TwpZB4?Jz{T24Hz=BZ?ESEx*qUd!?K2G}0i?Baz%9_TKKrR?n2B@#xr1(yCcio7@`6&w4LbXC~W2#u;(4 z2c%Cm;?yf~6A0a`hqY)(P31kxP#sb5qOO+btdb%0w8z@n{(Lb z79SnK%|)T2WHCnM)zrK6#EN=9 z@me_brIF-|zl`n&$Dvx{us>S0`yNWNe2PC;;-ZUS+jUoL_StRe|2dnmBpz zn1|bEVuJ^Y=X@w1D~#>DFLjhoo0rt;BvzIl)>0O^J91T0Hd517D7(#8^V!x=3DFzw zb%o6(!n8M-36W@3=jm+(6;4#XxibPFeAUyoR8dHQKDFXTn8UjI`KG`QW+S} zXJg)Zb=X!8))RJC_4U=WmV^z`Wp{iA{T!>EEG?68S`+#k|9w7o&;Fz#X8+geCiO8lnv%OhS1Z+my zx^2B$pt$I5H=~I6!tk5B!yPSfUM!|`%i8WN)^{(ACz5k#dA`}%IABK)BUhh00`)4_ zIg|?EAwbJ{F$X-+Q!`4Kj_L`e0$?tAww&wTY8$+%X(0q=c)enex*Q!O<}gQ>`WAOi zhylXdwP~MS=mHiixR}mPnKqE{)lLAHT(TN0ox92#0VIdWZDv_3b^~Wi%?pt}Y|51@ zdC*5U9belZx*G725rGK@d`zZgCJoa* z$jwSWGA8)d^O3}U{+y!aC_B0uu(RCXD`9f8IAu|EPse3ssgFTW#Uh-Mi!Z%Ci$kLt z(^C%`Jps4f&C51(JFult21U+%6OpWAImZ^_BOn_Q))u3dOeOjg~% zb;2`=gaE&k&ACuz?jow&Y8LRc*dgTBM-Tuy`~}ayZENM~4aq3ubDuko13DGB1IILV z)ind2d-sm(EA_eT#UK!f>4z|F0PA&}!h*%4M^CTkAXL+OmHdcJ9%CqZEi{G&*Pk1d z&tXR7aHwc~D~!CHHnAz+3G6kBk9`6R^#Ery%oY}?YtUlg5^oL1-xUXsF8LY*LCKO{ zH}pQRy%hs~qf@m54H2cO zk_aCGz`*H!V;(5DTLbsQ+O9)D6UtI1&-vs==Zr6np8iUAP;Xey$?W)ZR#}6v0g3gI zbvTHvVXo91`HJlo(`yQS6gL2%EExR}*76hMW=SB*#mC}P&YY;O_1Fj)fWuaRtNGG= zw9LY10TM_!b^W&ZHGS-fQC*}8gF&k9X&6zvBdL&UVJj&G-qJuRQHVU$2Nv!H|KKcB z<8^(_6!p5mwe9^*MZY&qqAJ12Szv8)ZxS_2tu5)bGDTWwkG@(<#W`oQ5ZOjREMf zE@-G2X@bkr;`wj?H@9jvGA1)10A`I4VA9@cf3d#)<&OZKmst_bt^$p?8#FeHtKM;R zQET9+YDVPvsLMK_5DQv|g1mTrv3QNWg?$Wx01jtF&Ir*@iUQYlH;f!?T(EfZcpWoy zH#v?eC0fZF!-NGV?ZNXrG8QgL>H8Tu_7T!KknzFr4WFM1FiIHfo$K$xtAWtk>sZ)B z&tAdJh_5lr&ET>h>{7ULLuDR-=2G|RW?Tc7Ci%;BGQN25tvq$C<(al0tR8)AyRxB9 z`8h+Zw)=yTtc6^F5wws))<@cpJj2ZPYulvYV?vvIaBu=?0Smw?;luI}9<)c(z6i0s z&W~GTwwX3FnK}S}-CIakRqgPo9Pj%pn856!%38jjFz*R-{?%0k~^kKi9o@M5MOn9B%AHTkV`S7uAH}JTzCEom&sTSd$3J!CNe(U<)cv=^}iwJN1Ga z;WmJIwiG?5!$0f4%D)~i_~L#3cqg~1f7*p}w=fW%xX6Q^ug%ya%W#}C661rn&6 z+5w$1SNS-XB;$-zvF(6VLmV(38QUf{a8}ixU+L4$p#ohM78X5CWzm_&faU-e6PoBq zp;yy;BN@(Vp-Akt;oRh@e0F)3in?+4dpXjPU2uNfbhT_qlX4m zevkE>G0c918s4z6{iT;)Is*U!P`#mX9&nxU4KVxled&s201>Vj=U;!0{$36|asW+0 zvcE7uyls6xHf1`UE_g(9v@j&DUd4|vt20}ny5TS853vekutmltwx5i0n4t`FtR~Hlop3tMt&49>cFvi9{`pfT(Z+5{}lT)Mh8oKimTAj89oH+UE>LLFA z??2_F4~hnrG^eA59wN?$^K#a;tV~$;us+1W3D>>n`F#LAdO~^eNbbJmtUMNw<2)r&r2IJEi}=ga7Bmx5bfuQ|BjF)drS zKj2T;scz(%5EfgV?vHaSM`A2@b(%^J0ai0TgVzaE_68dG#u76N4=uou8Y8nhaoK>Y zM&isS)Y24i9UkNE_yHUc!uD?;!x(e02e1Uy^8O0b$kD4K#1219FJFK8VUr+sN7tfg zm0JX4aqkuaskPo(uwLPQNW6`KXqP@Z%kP;0pqMw!+dXf3hrCDsyjI$d(2!_2Y)jwz zB)z0xRrKfFIw$6??L^^xLu0Pf9R0~>IdH_D$Kv>})M5F#n9eRS@=0d}@f;8s z2NrsNYMOJsK8s%{v&Xm7Ut}*T+Vu9c!wDnZ959XajZg3!y!4~-0PO`DS5e#bX4+yL zV3|wlx``M}J6Vjc3A*}TF!2dC6G{ieHqjl8DO+F0^fl&ige?H=r2su!t=>Ur`-pkk zsd?Gg#1btt!DsDz0LRj)UaJ%?IWFUNM-1uaBGRKn6@SIzZhD0m1rJsDowFSVtak$V z)(AsQN9UeW(RR>=92Kfabu4?!2-~TRXmSSJILGEeIh9*y?i)oqJFkb^{ zkrH2m`ao4rf4S$)Aj21ncc?197(e$4_Xd=&f?=Qt_M&OnD836KPq0`lRxXh@ybZ}Y zyo)HV7H7|6y^Db!9nGiH!)Nd`#}dM)flcJ?R$?Xe9E<6qyGV<^?+bwyr%1d|pVgwK zK-VpYWZ}pfh-{rb!ct*w;)A#5G)yKu5Bq>R&i8Jq*dTMU;Xm*Dpi)jQ8&}k zApoEtzkk+hK2&W~?G@Y_M~gU4N7dWG3BifjUQnInO116M#s@3i2%Szvg^#+UPVd}k z?w(tlL%zBtBfe2IYqI4AR$!D93oFQBI>u;%x+0MRlxo+&H%%RKjtxn4buqer-fLNp z`cV(M?kmGu)bmPf%(u9^VEcIuFO;R_IDSn>5RTzGvJ;og~+p+m`bxQzeyHwCn(v2lF`h~mzs5?UAwFvbYO0RC)G$&*Z zTxe>Zy8&C)dQu!fxsD;14IjvW87I6j2#`S2q8yxRnV{3;D zVFoyxZ79lb^q1H9j4;m)?72h3_RP^EyOy-j`1YMDZq_E)Rw0B3nm}JNvYY(ScXXXgXF9#cwH9+dHNs|G~z2`i;I69g^y$lZ_)V zY(U3Zb!Ey)iHaMA&Il=CDax%M%w|KUeCPHew-NQYb4b3-HSe37clo6e1xvUW6tfuj{O@;f3~;Nyc2;;RKek$^}&d)mzCU)l)t^FJaSSufohyvuBAt z*Z3W^(-|QQ^T7FPN?~lZvccgI(WQV$jsZHvYVo;8k<-T7Fkim*M||(_&g1y{YHLI`cd2`d0JWhnXm~skZ_FM*VSVzzRs#SdhQO*H^sOGZQ z^f?mp6qZyT;dfjH?A4Chg1xU&SGG1{gRHG`UoQ)7;g+u5))E<6Un^W_x^MRj0wo}yhj}i35cplG!6-_1Hsw{kE-K^RHdNd@( zl4$B5i6?+;-V{P930*jA{ou_M=bnZhd8VfPtX#{v1$ChBQKJ(@FN-J;GN$fF4yJtJFK3_88ak3;Bi7c?J^y)Akh#r4DoXl7uZQQW0yaw}tw9q$vf z+-T$@MpF?2Lnl7$11dVCi@~gqz<}WLRH^MmNogkrHKcQ4K7I@fW^jAPm1{~LBhVM| zv}E(X!n&#ImXCyIN3Nl<6+G|J{ykyKH%U~v_2wg;%2HBIDovvbKzpZ z0m_V~@L~j?1VowdpDtEIVY*=P+#-hQO5EO0^uz#;!K@}04ZVJfD_othU52^Tqq}Bf zrTk%yg|Dxn1XSYy5ZG}YMqiR2XwKx^&N<2eabS^c47waGKey$Tb~I+&IsY54q;$!z zu5oLRuouIWhxPYl(k{4lTfO9($A}Z~{13L?aL5fOTmzlmaM+ez`1!_A$DTXvm~z{G zdFX^|ZLhmwbOMe)Vu|7UKtz$|iil3B2P~xmrwFn1c+~)qDa^<)AF9)YksT?Ro7u&6 zPuxsGS+r=%{TZNI!-{%MoET$J%0ek*9*R++yuJ)hSp)^XUCkcVu5&Jkc82eUQI+mD z4YGxCdm1ljRi9`pHk^?y@usywv6<*JxLuu4MWW*Q4wM||clqP;;c1!{i@Rs75sNv9 zUM3mR6InGy%oX({0ljo#Qm*a1yRJ?E@^nv;8pF1a5==XzR(#w|qm>K+pC4h2%W>HFWU7y2Cn1!dD- zEJG7GSiXw0#%fi8SHGI`^xj>O(Z#6rs`ROdoNT>RO@%?YKVU&MZdyIZgCJL~SrJG+ zld0q8rk#5MIE9NRuGIqS1-~U9dn`}Xs;VJaGLWk3K!#z)zvaPIM8;c6+2FCiX;$gP zmv+>D%(oF~Y6Y%6v)uVnL+Mlg{vuLv=evF6pL_&#;2vbvH z*)Y7KYD%eTQEcQ-&{jPSw_ZL;Nz{CR&ZLbdUE`j!WG83F{4oJCmHR}yA}j@6Db|5f zX`LC{C0EK?a|7SN`=RPnd~D@T1Ve`$Cf#Fh&6ymjHCgN@LIK+qLVl8eV2V4&d6wF$ zk^%r;?4c{vOi4uqA_z{YQ~=E7-%(Bi`&3E|Aw)9G$dHBYRz_nEeMoL>t|N2eKn++u z!TaQB8Vp#OvC)D*sqr)gLxz2MiZB^CFo=#a`ST7%Rsc=~a8WIcY~fM=`Ynr~>#P>L z0K)Vnl@PDia+(h3Y8cJk%;R*wr9XF3yhQ7!5tee5g`&;bm#e4T6`8})5DYhffr zVapRPKX{N0kfUb;!humi82R$Zo(O8I=W)q%oxHB5xeiQNyStX$6t9C>CgFAjRxklr zHl!q(%GNEqSsO{$T!v>YK8@aKt_E^(e%u7pFkU_Vdb!soPqyzh)H}t(w#%A1f3m#u(4$LvNYuQmC;?f;= zuq>%*nvD6tCxt>_!*n=;Wx^zbq;!lfG(P4XmOV915u@gZ<`rjq*-_*MSv5hnpxU7i z^ix4+FiTrD@x#+ak)znM&2hx{B~}lbPCjK{wW6v!lde+A))=wC2o}Z}SeMS^8^jMX z750#Ds1unNJP7~0eep!dhe(T}+Vg!csNCgM`8ZegYW#vO3SJy{=2r3i^TKQchm@)= z%>-kahW+>mnjsW$AF@ku?g$^0&9&c1XIQ3w-!;Zf!R#$7?HX`wnfDQaN5G>;&Xxzg z-bUozr+}K3d^X{Qt&8|W{A3CI!u3dvTo5f=f;fl_2hCM;39B>(XvCtQu5(`nB>#y; zf0|w`06Y|bz4lo1Kq{>4TBISo+p#0(UqThB}8oJF1g(j5xBA@cg?z;lQD( z9t;6vEF0%k7TD%FR*yP~6i3ybH>&|g+9}-(4Daww+t{?aOXyxQt)2Oc{YM~b-(gT18M@#b|B&#h<+FwSm-11qWe+Wcg}_W^rY zu|cnftGb^_Tn?rvn?zODEqH_@)Ll#enX%Gu4XX!I`H?APZ$`88Wd2!UxM0e{S0xXh`tV zan<|bL1mZ92_Kd5im3>F;QQK!Is9|d+g4fCw?N4X3ntA|A!-$GGx|m;QvPjwlq1o* zc>TOc3U7U+=D<0{MRGaEXgA%^rHnzWp6Wr{KCM$0!sUP%ThlpPiwKi0=CfUBi=+Yy zfsc?Y1NTPFFUhTt5io7#p7n0nMwQInxAH21Q-p9?M`7!lI~0c?+v7Gh!vzJ+Ri2q~ zaSuKpI{_Vf*g7=vquQSf@angtTROVTa#F#%DDxY))x@{=+N6aOLtSZnjnu06ezUb< z>+|k>GC0fvVl0?p;tIeurt9mA1xJg;B4gRR`&W4*RZxV6!7(|z2s-oTT1C`2`5(uV zBLcBX-cb}p^DL&Yh|h&RKV6t$8Qf{k*3fp(&MbtQTHAVn>!UqVQmX3~9jv?@OI3x* zFD_vyMO<=&Wqiu80wqXmlTKa}XKW3V8#L58ss;;DN0o-qDB8mxWutTF8=quKMSWPE z-8rSClg8IiB@xIfNy<49{KMg}EwXWezo%|#gO#GrPBi(M2o&y6kCqxmX6{IPUcw3z z6h&Q(M0H*4$+lMW4|)SJ>m!j>!&NgkalTY3HLQAi3b%f`!i>J{;0D_~eXkU$N~N5J zjPg-xmm)jX*x1M&zTE?;%%+RK#Oq!+Cd}IxG?i9t)N1-H(qRzj4OO&34kahqw)+Yu z+pw@71kP#H)Q73Y)$ zD@Op?$b(y-;k%>WYtACZ9FoZc3{qPz00fDl#zv;iBlGUX7F@eU#ikYqVT&pe)pfoL zM)X2oQ}Z2Zj2ud?m@yW0HtK22033!fN<9?CB7~++x@_Qy;}ws2G>KwbX1XhW@a)w?Xt+;rP#99NH$dvHjbhT!QCa%z@Dk6prg)Mrh|Aj4OyU-A^u8H*&j z{$-j@^Xjy_g{!LeUE5A$-AtMGPx^vZqx$Ms<-0)V8IF>#CHuEy3nz8FyPbmt~l zg}qeoBGs>^3cg)4oL=*R>ySokA3Om|s$7-8#d1yG&G4=6^v12Y0BdkFG!$kVS8%K^7LiAf9^oJ5s~pjY%Ms>R0et5T9%IEy ztfSGON8rkH?+!XCb+B*_2A1zTs=W8(TaIw5x@|ik9w;0_m8T~Q4%&yT;C<7i%#vqbd(7Pk ze<8dndk-2VbDq&or==qiaqQU>ccv;5^(<0tL~ix)^Q0Js-8Yj?z)r6`zrDK@d1%yg ziOu^pGk@-(ikTskp{+)rreJ)Z~_H4kl+e!Qb->@B^8;xtEN>uhGCjJ3&A zo-?;sT57vKb}D$(;!@~nZH`WlK7Ax6u={jHb%X(*2|uC~fAGeIw}?Nhx$<1xe*WVD zzk#VrfT_Kl$!y!1nkRAJsWK1<(zTcs^cCDBi8Vp$c#ptifVRSS%I` z=JF;Q7BF?k$AxFuTj63|gQPSa@d5l>v?Khk*!osw7$u( zDl2b}2C~jg6-bKY(qKxbEk+oXM>MTv>SSdpJA2!q+3z9h7h`SHL&(%U^3VcHjAfKk zrBH%v+v_D$b&5>^{a}Y}&SR^A4-ikt&Sy2H<73D#-x~1ZWe{5{p@v)0y=g9rqZlQT z1=tZ#IVK;b;_zle6;1g77|lpPRd!xVXISznE>CdGXO%GN(SeH#bc%A0uNCTqjm4D| zqePFa$$&`}4W?2SAO(kHsW`CQe$5eFUGjpU+Oi8WRqrTVl6)uPlJf>PrjyjcS)6l3 zNJBK^D$j-{>#RK&9H12ey`6h{AC7Yfa8Us7++)Uc3x?7y73>5AVY+A@QPS(!f;h(` zgYOeCTot}2dlio%I~GbtU`_^1#9;{;YCdF&BCOuQ7feRV1{y?H+g<@Q=Gsb2>Z@}Y z&^hrGAebT%FEeiX4k%jHx#@TI&dxxU16;?Hewc!~X0=n+-Z2`Vu{L^!n9V!%w1Es}IYyR7$N z2e+Y38*Q-dw3nb0#iDXN@(cBaBNNXk#>=3A1u{3c#7E2(*+XKlVznU8D!rhva5lwg zy9@bh%66_Pt5%DTyoChEcPvE2iBML`XTEks59Wmiy8so@8rIn?UBp$v)oJLpI`WR; z3Yc4HwJZfH&LdPFGi=Q!=PN^DBX!P1@W#3Te;iU;30iZK>97CR8UZ zTOYioQ^GV?%HQ(`H@}hobg+Zo{6|KE*(DS@lye?&lA*7Q{zC`p97uT`{!y|fi0qS# z<;G;3=KFs{Wf_vwdoL;&72JhuF9Ju;(K?!f;E0OWxx=4b1bm9X&f9(-Ke@sf4IIRH z-gDhA*CS ztcuqly5%U6LfaZeE*vX&>5}DMIVOrXb~2|NN+o48F-5W+vsLLL9q6*}RAageSS3Y; z0iu9l#6Ohwm6|9>SXT@wX2Jw(c|XY^VsOrM1u39|J^1xpDJEim?uhPLn=plrsQZwH zYMu4@jd=?oVIl`!z&&eoz^!G*Jb34wz26l==d69``*5_7p}84S6CL>X&@7Ev%Qrt_ zb%tn#E=7!>?l6G-@;eMqd4+bad`k0)Hfan;<3R=>wsI!0j(Ry00;P8Fw{$VriTrGBz!WwLmAx5@`b4BQyAmd`zZVSQz3VyI0L-BYulMB+hNbM>kg zU_uNz<@&6VRYTT>O!!lr39Q#oFlhd0REWo|i}vtCY#gsdpS-qmwtCy3pByr~)=2A@ z(L6-gcUOqh>4L~3j=0*hy{ZIok>@xYRm~9>zrYVR9bydg-ViZPW48Al?iP!%SUk5_ zEa*9j`0~pT=ixXa3BcA(cx79CeHy#z5N(^x)$h1Gr{O;!Ma42Is4n?qb2YEkT9?Xn zPh}gqo$r3|++TDYO?2DV4(y^FSX+xkhG+-l;_63+k_nveS_j!A$3M)$f#ED#3#wn< z80U7Gw|#IorA^5?(si$0;cin3t#vPIl}xMI!rN~Ic6DgYNl@$x4%=2Zq7Fu5;tHJ> zH;Uji6hl+ChD~e+iI})gw-$Jv8v;$yRCb z#||~lzi|50DYq4b+xr#Ql;9%|idCGWYfYRFnmuI??*8p7=Uq(X8&Cf z%rk7;r-V7gZ4JT-*|Q=w!#MB-0E1-_CfPHg_wfJ|v0&O$4a)dL_n(a)m`f%=_CYS) zHBX!<=0l(82lj34O4!+B*0?bS#vIC5T+no{);VXMFtcs1HnTi^ z6|ZhF^hz%~SFvDiPCOrNEr=cLQ=$FUPeMblYGjB&T66WLNzp&8?_BuB)O~r_h8HY1 z3%g&5a13McnUop|m*)_iYtvP12I3L~=h$1E_1U&h=8j#pbWcpu!H30%A#G5{Jq)GZ zA}*$2a>$_=MOOhRk*YAI{42mjKWiZt2=ieJq#uA~d4gy#%*%^}5N@X!hcGvi+gLA= zMLGcJwCn0uaoEhyoN!I(fL%T(IQ-dW-)1$!4gb?+2y5()de1TMI8ZB--$rycZJ4xZ z^ZoiZEOj1qAI*`yN)uxsQ<>1Gv*K8_*EVk$8&g+p-Sw<&5I9Lv@?%t|urv`5*Bq_V z%BgYsVrTi0RcAGA(RW=ZN%+mJQ5fq-UorhuyqlCi17~0CO%kG;+XN+ zFnSUEHLL=rQ>N1?Fg2LG2?+dcNBF@AQ$XfOLCUOLfD^+X+qqMhTb!IEiIc#X zu&P@VtI#%yf`1WefSjM#W@j`?s;6wFehsX-pigi!r;Po{<<$8F=y67AT1|R*_ESAY$$+) zMR<#;h|tD*Uq0eS7^bHgIKS9YZ;=ZHQ_$cXKYsd|>8i)d)tG zOcNsOIH>G2{RgCD4cfE>BqSeMUzlXi1c)i&iT9NYAG3WwuLfZZe*vo>@f8mUw}8hD z^8-!V=?5ybU;*Le$kk+-pJwklyC+cG4Uw4Kn2VoY*1*9 zohyDKbEt0%vEi|#yESg&=iE;$Dan9&-%Z;wa)g|Z_;a(d?G%>uGJR*OXq+GinrAH^ zx@YMos0m@Nqp^H3mLu8DSts!nfTwFBo79eZi*VuPa5-C>AZqM;CRd@;D14z@9qwV7 zj074Exh$&W%vQ++MDuJhV8JGy-fy) z_UB1n_e?{TM*?d;CUl{?X4_&F=w$2#X-nU36+tCs%5(XDbs9g2B`!r0&siZV&PD{k3InINXEmz<7wmz-Sxc=zW0f;}}#Q=jc9Y zo*#ZSTkpD9e93S7_&NTG&ksQ#nm@nbIIKYg{|y-zejKm$0tq}FcHc+a#eydm&jAY! zz(A8Jp{~uVxdK@+9(9c{w-Jk!hRYnG&-G}f{}a2T2%|@wDLVM=#qO9@utPc*>(H{H ztzSO)#0Mrjzu|ny#wOtBiZ6ElbGhCAJNs_6lsyCw0&w2ySyP?Cp8UvRdC7JnVP{M~ ztS7e>&g^_H?}8oVtwcFRcrj{M`!)F4g0$fUBO=s1z95|NRFs4%_ za2l(N(g9ReEKFJv?QdAps@`?KP~>G zNML}#tH_y1!-bAc&Uz)Dk8!|Md_tn7Wi*Gq^gVLmp?evmL-FaD9Icr8!CB9@bMPR8 zVRuL1aT3knPZK82^qh4*;LK?ZMa|`-CNmdc)!2d;RqG%Dkyf-?nl@KzNQwt~qQ~YB z)qv3iiEM^_859r~*K@7|Bc>xPofK`erXfj~-SWuyLhfR+`8o6peUuABG1{7#=#-z! zc@=!`wk*7;^E@|cTi0&Mn$^V7%bUHa1m6xS*%c~6!!K+I@b*U@n55i-#GF?-v_Uv_ z;QIyqO{=>;J^b`#jdc;e+WIaKR{QP*SA>_`^333C%h==+#s}_Pw?6}5w4-N|L~kT^ z?FT=!8tXVH+wdHqO7Kj-QT-*q^~QI+CTYFol!mSIfF>|)!}xrw6)2=G+7<$lU%5N7 zv)TD54s0Gsx^nxs$Bk}GE3j=A*xj;qht=;F9bbMM@Xc5Rxk*uF^@HP2}bSF zwfo4yz$s@AS+=G(Y+1UK@!8nJ_?d-W7jn?@fRk-9(~zF+pQ&wu?z?pZr1)q^f1dyC&bwX!cEfXHx*l2Jk8VRFx(bipU~Uvg zAUc=6`pL3Uzx*`b=IaO1&H8zFpT`f$gX2fKhGQ1M_G!mv#+VotaJ4_$%p+i6aj{qg zB5|=mm{Al&R{$?GI7&=r$8r-lgy}~+*o@FLWGR@v4{!Ky=^^L_K>!_1qGWFxs?h1FSra{kmGR=R}CIi%PL;zTRny znS|{i7jEg`iq0Ztjruzyz{ah2oHbC=X-e2=8J~rWL8pjea!Opk1PIhn_*)7?p=e~ z9hz%^KtfZ6`u(F5dtdb$14k01jEvY|*LnTjo1liIeSHb9n#2LJl{o6)oWiHgRWWA? zQM~?oI4djW{4(yh3d}9z^d}^)TE0&wGj}juIArjw)|h;$;SYfr%)s=y0&y}t?URki zuSus@ExXT3ty(X-9hr$KGu_}bGMt&^McQa6rrg`Dx*4Q*lR2}Fbrapr1{n&zF0!gt z-3$tz!z63M8CyOCuBX|P-W05Oz;WLLFMx=os#W4d9nL@{OP`*E~vB0mS+Xa zpU%%4R|3FTu%4osVh&5W3T4Go_Nt4v_qi$42>59>)O`~KH31u(3t42{#NM-q9(Xl0 z)qeQ)(UC><2i(RNEr0%E=vr?Ts2|B7TL}O}7*MQA$eXmA-h%bWvr7a3CX%9xcY^4& zZ9sjjJM6mUHTp=mbk4pmg`8m&aaU# zMt&XY<}rI!IaamP5YU|t`cY4@xD=bG<=zqlc87=$2Y2%d>x)k;bh4*$>ClLCCx?sTv}PkF^N=V7(Tp|$xmXFCGMXe(I7FaD{oZ#Jxb%SW{+(oh8BthDD20X8q* zqiMX4F;TQOZ~)DVsK{}c0F5^4YiIFn+F65j^_|ag$Li(<>2yey*gVAh^x^7I+t>y8 z1u=s=Od;tW+p{XeJK305XKwN3^){lszkO}m+LJqQ2!~{XcmWM#F|O9iaCWZc-r1zN zdHQAgg^JIe#l-!R`_91f7Pja~K&B)-J^7_S^;-^Y!{YGvF~XqnZgXU6?aRf_C#JLF z+OW4rot)|fijkCMm3OW{exMeS18YT(gOM9L!OW5~Nunv%wA;}l>^k3~g#RjvwBsbA z{O;Wb8x)YTBATJtaX0Xpvv~~HV~9@W!_S4rwtt4;v^i6_7#pMWY2DZWY_0?xDZ{k3 zIVhwL5m$hd9xJZcGYKEtG-fv&q`B9sXZh%|2)MOQ2e--a@LL;I0LX<2N+|r&tUo9k z)!LFZG_wpw5{*x=+rQZf@LgjOcY=?1IGkv_^b%f}s~2_H>N38f!N^wKFmRl~ z)T#qNl03D%r@bqWd}ps7G>`=)nTqcL0dPGxhk>Ib9_JdM+H|?kYA?643ht7kn^)=b#G=M8SS8f7leXaB1_hHLL1M{X;jLROG8piD{*qj1k zLBL|Di6K)9i=y>gzJ6-B6(yTma_7;;hV!t-O(&_j;O$NhBROg1RjVt$Kvb1znt&na zdEXLCow+lNG7*}NP_E(c9B>083*>AIZ=DZQC;KnK2;kd+reqou-u+%D0I;OIR;PE6 zV5Pf~@T8iIKfqzEoK<>00(IMzi)I9VgBwM5!<54HL*5<&+l}Uh1Bd(#g>8zlW!(bV zWQi+S#owyv)Vy?14Vu-svjlRX+&1B?MPV!5H)e9@$l>V0rM+u-`*ZTk#oCTw7_3>N z?vcHdRuVpfQdqykSNqbI0BpTj zSkQ;a>6X#hh8Aq@@G-DadBvZcb<=iM3!*XeSl0fd9DD?@_mU_G){VJYU?%OUVXq+n5G!`A340P~N*Dbr z9sp<6R=J~^OKuoDG-U{Y3$LOc&v}CafD_tmH7R{c^~k}U{!wHDUOOF^OQ#(ti*@%W zo)!e=A-g6i!581+^BL6EXUC#UY&3WrKzD$;yLm90N3*NQ_sV_MRFvGnk=59;FVJ<4 zJv61a6(4?6?ZMzcQ79_`I$$+Mi5D5h!utk~7Gcpi+Jm(^T9=NQT8_f~-tkZ44fZqr z+!iA(+%ezIa;9ekFaY=&z#E(LDo3LhC`qT~0V1K}p$7&eZ~#TYqLEM(10opA!jq|E zHK6rYTCKi)G}6U1H&<2GrFqF+?2Ep)jMeEmAcXOoHS0BgsUgb)E=Rz(3gAY;_K9^6 zrY9;^8G*JH@vfoFBbh^%JnRG@YAmY_`Pf_n>D+QWgX{?pNvJBnC0X@g-I#c)D){+f zOi^+r@R3s}1{Pca`28UBq9_`5U`eT-FkHmmU*N#9>vR6H-y>O<6bUbdInGb*u4sjinIq>rLd40lC7LV&#$^lo1=aXezr(cSc8?J8`B! z4XCskx-*x7XvL)t`~YJ}S4|@m9=eYYk%@D0a1+wqSRTtQxxq{r#D`7TSvC$8G!@=! zEmIa{WJL|t3;M~IVSWH#*nGE_sVe2*ti0m3Zz^&V-krPq?uiB3nR#}wHnjrMSMPvl zFWSTHd*<+8f9mJ8w~qO!eY~P&XW(`*EhHp<*K1?vCvzA`3vmyOKDV=9ZiT|~+bRZ$l*BO1g4 zp4$~>9Fa!0>dfW@Ebo?MOlxDEQ!5;*i=5*qIF07W&{*Ijl0j-|Z`ZA}lOo-T2o=f(1O!Crk$|v1Yh(d@yio^K zdj{)Z_K^CC^=0b*)ZaWm-S^o%8(p!^QZ|n%akAR#d8W!AX4gi6wGAI!4LpH^?7ZorUB2yk_91DjzCAU6vwH!nS+ZBYn zC36_=P*;Hs>-h9r!!d_O=hgFHGPV3eS}Eei`y86uQmUG(%giB6{Xs_95?4CVrDZLS zW*s_Rwykt-(4Of?J9K7N5r>`znu?*eDZgyInC{uuz0RGbpp+OkY!SmyK%(MP++zk1 z%8+o-h>5tklOpXXe+2`bmH7~ey}wrS6{AgB5;I@cp%b6LiMVN2V~F=7>r?e*wJqYj zDIb16bjCbm>3ORXT41TIlo+prHgPno)1~vtdPM;%2{~M&!Cl*7v+h^sN=}qGZ&`F@ zoWPoV?@BE6;!Zs6%#Uvx$h%|*QDk=(pxfU2+t1~4WSjDO3XDWCXFk97^Ly+ZU=LaJ z>*8JUjpsl!pP4=LX=LPQZr6f>uX&?!n(j6FB;pUdflQuZDbF|!vj?|jgvxq7S3qpa zz=JMrr6@KdkV2zuPr~NI8r)Y+XHt#@!Sh1~WuJ zoiK=jjX|Wl{BmrH&D^{aVOUtwsgi1-k(poDo1{(8C;@AW&ON7Jo2=^S zl^s+FK^OlXHg?k4w9n?uybta+*6C0>bKk`9nD4>P)V~y{fO>R<%ZMLvtd(;rFT9!2 zx5r6TPQrhBEI1LLM$4w!O4WzQ)L{)XjJ7#i3sTGa-G8`H#wHdF4s;4pivnkO8g+ca zqH#Neqq)rQEGeen@NjYbQ+-`O?RS$ULUp0dvS8T)1q!fI)417vecacj<@%h9_#lKB z@Lv&|!V?}EWx))<1T|7~ts_($-pcCPwYu2Gk-4uqaJB{lBwR;S5h_n;)O>JWuHF1B z>rtf7m>J-ERe~_7cRvL^P$)zu;b=7nmw*M`u5Y9&MtXxkv|*xwz)*wL!I@w=hflC_ z_7Z^MK}D0S+9ok;dJL5ysTyOz241+YSet`+RcOG1M-eySBuKd`1q4S5({eiFJUa$( zo`V!LE@+;sH0>^{0oi;*1Hk&zNkL6ARjq!dI(!m{i!8NuY~47M8O(0u1)a8*iS(A#o6{g!|4&Z03Cj?J28 zU(w6jLAjJNCRS?7a+*M3-oEGKdeTan1x>~vUrk89vkK)+Sfq#yjsteCjxEYCQ$Q*> z+1-lfr@nr5Ioigv_nTJ1oj($8j^b(#?d+7*%DaM=7WZ_(f^TAsumE3!o$8BHjAM>~ zBlfr|Zd`rzNa$!CE5EwtH?usr@l$=he(GDdbI+e2R<0h##LDQ~vHuw|DwKZ(`5i!-=mA{}}h;^k%@N*l{@l zr^qNB1c%bts;82UPE}AJOIC+<_D0#@iSx8tSdSnZ{F|Ydp8x8Y`TkOZvoN{WOWoJx zTqme@MM|Ie#E~R`wo!!^skNYD=8W629z~YPh&&kfb&WbuZxz*S!JR0D9D2|t4?SE{ zM^@KwH9PgtQk&<;E1eZ58g1+znV2MjlektfmKz1nzl1(ox2ii%^AIc z=kSBi5liXEaWH)et8s|(%8EB;W<^FYb($@=A^uAIM7zLM69tt2kg1A3()+AMYo+gu zzmYRXXLbrC%tbQjxjy4mkZDIPdfLlzPc*jShNt}S*;7A(XO2;_^=j%Wmc8oI^Rz5J zGh(FE%d~T)91YU*t{)U`$MW%N*J0c4%{!>}c}Jbf4N{BsUd;@d)DofF?_ShZn%6Q^ z^3@U7BZ>?wc4S!{z@?~-nTRrZO2r^Ioql>oVh65|i@4e&iYnvg^JS_?lEBrlONx7H zg%M?O=MYg!CrX`QRQUvR6B#0S;LKlqn6UMq==#f58O_R7R?3I-#y7L)R*r+{a)t1h zv-mwEO$73R8+O)MFvE9(I}!K|k`ig3m+9^wQxg?6_P}68D-SzKMOH1zXxs319ac%1 zsHR@d+Zv+CJiMG8!ZsZI^?zq2myYai0aG}q2n1tBTMDsr#0Wju%PyB0dzJNa>29!J ztpAH1Hsbv1>)TJ`P4@<$`m+q#i6<)HWrjGF6&C}m!!G}pD}J*_dc7Uhmgw{`AhrR4|f zy4z~WbHTH!)#+%PRU@%cpR9hO*HOWja$Y=96pi;C0mU7imq*vxRyFKd-t+1JsT;CI z3_?=orq;Eqt!trG9?&4^yb}u&=8+fvRx8tRU%& zONNI`7?5-dr;Ij?ZEg&gH7fwV+M*%s^C-c|^Lo|nTHD;7nIvKfBGjhudH$X_xbsey zjP6O#UrC)kscwB%v%Vo_c&z226zqgo*gykJ`eMvSH^IT9yjIH0*2^HiG)cQNZJ z&&OHk(@LhH6wl5}wwUfyUO$02bz?SS%xhj55H{I)BCbGe;VYlL%L9rkb>~x!?l`c> z*3b)vqV9pCnSAix-p(7E(`t=gbPSH6wufAKBuev-di&A% zIeH3C4n4E}JE8`eW30Fd-=XwshnCPtRP?pi#8F&mEGoqnB^_F6mA z&M4$zZ8LVl!xnWP*9TqDBE%tIDP(-J&S+(iFdB1sUXHaHaI7y=&>@)vva_RyoO(68 zyLe)v>%Qf>vHsc07&3oigU5zeYuen{#ENYd8qo1}F{&pCFHi+q=9g_a)ryj-`{^Vv zG1%||Th_DQDE8iTqU1!a&5lw-QR=wH+!Xm)2Kc7R9q0bK^~vm+tuo8=Edc-(?U?bj%5$NS^{GpFtp5ML>#-i zL;lZzpFSDzM5+ucCQ&c~>L%b^KcyH%*#RA&Kc9&GE8%WtFRx4|E3Daj0A;%TziSQOajIw~p4 zhXKG372i}PYrT_Pqri{s)q<1bshz&?(h#l$yKdnrX$~iHvh(WH*%FK>jK$T(Rwr;G z-qQ9egq`_jPcMx=W~!|3cINA|_xbi2VGit6sQ|ghyrG5ndi^9+E|~YKyUMvQki=Qu z)&|VY(s?8MqyskkZ|yqCoCB5YzjLXU3%A@)rx)+13=txo=`xR_@IYL)splAB9*Em= zc`~Vb{W_t>C?;5;)7TmU$6=Ncz$05p!jeZH(9g91LqNR0_F--DU_e`ewso9l2KP2qzZ~HsZx3SKHQ~yDB7%3c^?X5{1XxS{|?rQNVzxsCOw-2az-qf(( zHIhkte(IvSXwRVVm>jUbdR+4DQz-jZbPQL zRVU^CH`0cb^r<50bWq!{S-&9?`{4ZHE6GkqZFZuG4I++ zYRL*@z{pRrv^+TU8gWfn_FQ z)S#m-M~f+yGG=^0RimgQ+V`=+ju~f_88to6wltjR1)^fPT#&xAqb#dsD$1O*nB_B5 zWduuA;*ugRXcR|}s;m6PUVMWR8cB}=H>m*$4;Vl_yvx5IKrpwS%;kkVXCg&L5fk~-%K?T z7dl8~)eIZiE_M=}>Z)-oAPiW=d5c z>>F(_8xwfJG=3>|6}+@vEkN^TJ^y$zs>TPsj19ZcVL6}g<9B$lh)yz?&&P5dop_(u z&yhu=)e{39%`J9V?4Z7D&{!M$o^qJgvT4rLQKzdFX_?@?`Py&}*4tv0WU`Uea|d;z zp>)^BHtvg)6a6|-(emq7R++@g_B?Y)v5T@DExe9qQRa1d4G}R@1*P8UDejlgdOnLk zmEB9g;#tvgM#CaKAqnq}hb@t~7c#eq9`*z~Lz*WZq7S~E?!OlU|u$zQE8SI1SPVHVg{^*J=6x){dal-MlqYWy06CXlvr^VFc@G{X5YQR4-_0Tjtc;BqoDDtyS0sI1n{I?&H|geK>wH(j5`C{&33r znSXTjBB)eD?tmu(@O!Z@DHr zz$D3nnf(bau#o9o5Yoe^rg6;qwlSRg{MLS0SDQI@gYymRjI8j0!bezTI*NlYQaYGr!V4q7Y#R|vz5FosB+hDc(X zhCcI09gJbcp0Z6+{0fGds=zDV`G_YD<{Fj^MJT>oAQVqbjwiHEJr}9g!amzT4;mZ0 z!6w|dc-2}Z37XZN0snryS|7y4F85j1J)Qtp>c~_Sf9Tu%;g8@4A-wGI<)bf)-s|%_ zAE*a3M028n+^%xtscNmM+k#S=WUE;)nuy*fxPSNPN53E#9ZQ>Abt4bD2XA~ecC)Pj zqeId;l(iQ53x=z9*%;F{SeHRi*XBl3nJeE{rit57<;>TKpO1)Z3p3Zha-vozE|(}h zIZOIGy^(LuiSxcAy|eSE#;SkU-#(Y15@Xoju7DQM+y+(;rQhd=@80{z?*LrSrWuA7 zO*Xvp)W{R7x2JK+=I4boP8ulJ-9+v#9Z1*`@vwrCGsOdNVPJ2hF>w$RJ~4Ya15;9q zY0>K@BLQHOsgwy%9db+vWqoKf%8dE_!(%e=6UUWyS6beWr*$!?T?hQ!9d;(o4FhSp zjvGoX&Phs5rM8wa0Y__U8lMo%3SR}*jJ&uxYOyVm4*ba|F*c^TMI>!3Gm|B(9dE?0 zl0UZ2NpY5Z`LS0kQc`vhsPu-!Cq0)iiA!yL2qxi}5O>%%p$s5s4+Icu$`P%_@}UgC%vfLd1cgV-{xO?Yk=S75 zkH))(#Uzv-2}h?&G&EkKHK`Fymt#ekk}$)bTQnB>XO|+b=Ny5W7)}0}`c04UqledS zu5P60C+FX7b> zT50UrQgVBM|4Rvuh=uEj$~WSZPE$CfQ)(|h zWwZ3+Vw}SKTe&V{koU=H4cnj{=+(P?m=@YH;u$}vI3G=1u@iz=`)7>vCr0FPdu*vs$XP;v*)Dt((g-(GT)S}Zv~7Nyn?lKWTrphLqd zJ|!H5rq#NH@tRaDG@L>}*KUl{J}io9R3oljIkD}%e)`Q>=Vvl%D*a z-baaCjw?9zuM#^fc4j{QF;9JMxv<5u(O4`kmZFC`(cQ4D6sOi2om@VlIRw)sEiA8P z8+z;T+Hcp|k25^hrfc|!US0e=^v-lF&<~w?Ts#bHkDyOTo^d8(59nEh!_=JqVrPDg z`E-ev^Th$?Ya3OR$TNTXc<-Nocjm`6_JFq0X=^!a30vz&w>DQWMnI-<>NsnMGhJ&J zAY_BSkgX(0VU$1^W-9%@&Vp0w23C~mn1zmscQhHiG^%RB*X8vB}ShBeTw(1v?ARAxx=hcrKl z0+L!$)x4%wX8qmk<0Ob_DC9Ng;t#kuE-{O2($0oi=kVbSPcc!*6P-^?Fw5 zk2bN{npyA(V}#qO=~Z^d@)L_pQJ1ffu(vOi+dfF-0&hg+?quC7K>2rL{sOHC`PcECyo0_^3B7b8i%+ zK6nbi2vd~N$s*v8H~^z@%|27fx?kO7dNjw4xa1*nK!Z?ZG80o&02U>X>8Ys)OBnE2 zyGwu!Tu1o|A`*(jMjV-9K+YAlqXY*rfOFeCSjQSXv*(Wve_R+>buNc(?9i{3}Bkylpt{xq>NttWIh1cGJ*sx;R1YWuX z*IKG#6<_yhw9ZFn;)5*3NLgiOvhJm0zEqZlNd>$w8DK97s?xj0mxLx&LKq56QV!;K zLiUgL)E;&M04hE;!d?)P^H!CP7((x(As~Fbli(Ru|NZCOc};`YMl|KaJoEMGDX(un zH`G2XE131QA0IkNpuYH0fUypsdfU&J5EX zq77l3bAk~s495DLLuikU9stG;e+10it6#E#I@{wdGq;&Q9I~(CygUBntdGW#dt!V* z%xQx3EX%1+jawLRqW~H#_OZPHJvmG0=0d3LJ--~h6kgHZ8c+MYKmYVTKm3FYX3~>0 zX%ysFxZdbD^$ZA1!nTRFbUh$ovO$YLpu$24Nt{Jp&kr8kV`i25K`{@>xCX2eE)$6C%eC;l+pWK1x#!&~F%kua=xW z5x_nS)Bu;MsB0mfXbLMxK%ik~05e>NTzI-kf*G_{rHepmbU1dnhzA!pcZ7b_d3tVt zqouFF?J70MIrHSl&$oW0L#^E)ME``s+E;?36gw2aQu)4urPB;B4fZ zfuz9Irgj%*-o{lmSCxI^VewJy_#n;Pfx~B^`(~TB9RvSJa?LgFn2$SG7?QC=L%u;I zbTq>!YDzCx*b)b;6R)yyI6uWaCor}782F#%DWBg0vZW7?o7PZ0u=>x+b(W}O!rn|E zb&$2OmELxJ>%F|efNn}9FPL|usm<|IPj%2hY*YfB+3*qOGUzUEsr) z?HA1-PHzaMS<8S@8w&^=5Zt<)w(YG0%-dPdcWWPQtB=v?XqnEvSYsa5*{F0@6gN+Q zyGyDzVdmp~;++z&fA&+wnLc0+o=qRqTy-+yb-vc)qDT^(-$US9AiSu zb7h)jBgaT!2We91J^Dg8kKC$F=`a6AP+SB6MAg6KJi7dtZ0@lP(4~`>w-^g^*x0H5 zV~l9%RlW$c#MFUd9S2G9`zS0~nrh8ahAI-H0!JvYm>ZPpCISa3l4F+bz71fj4`W0( zWzM+HsTw*tmP6YpgSa$Dja8rJ@>Psd`maVMgXm-x*E2z){OMlPMV5@fz@9df;7=a9 ziqFV~1$7)&|D(!8y?ODjxLQZ%<@mi5>TYM#XKKpWTZrM*j;Xa`OrYV-kH7r0~#Y(jO(EwS7)HY=SblTd)^xo`Gfb* zne+eOO)ARy04#wjyg|d2fCD^wO-0J&s^G9y1t-~8x{cJYvscB^G5B%fLl{6ScmX@e z8KFpqOMvff?CCN0Jr7BKjD^4zYHs*=zHScZ9^ZolpPlkF98!bIK9}EnKf-%lSBTtk zPNB2T$63w%0 zM!>pW&Dj^E-wvT|wZn_F4H3XduA}W$^IUB0G@vbxY<57Y@M0ER zH25^&_N4idEHY;ptJM@k$|Am~b9Bs^fwhP9 zpx}-LjTJ`-f-@h)HNz&O60Gg(=-@bzy$HT8)VeHG#cG>qgRsI4rb1(^6&5UvjEqf^ zgYQ1=2%qknhlJtF(HiV*S1| z0me9)Fd*W69<~F_2M6vnnA-u6h4F~9GEd0_j5V_eOFa?INEKdXF2f0Sbq^5TP*)6P ztlv0s`FkZMX7*=*XanGnd=3nKm`R`h^ahCM%+I4g?YqnYitOm1B6lz2EL&W>v3JLa z?jwQ6B-0ts`URYIoDkfOEcxIlKZQs>eN5RP3{NMW;;z-8%;R z+`Z=(SBxgH0DX*-i-y&g8iRR*`JcmsLnh&dnK5N*^Bkg+Up1NAORgJ}k2LFp{Nr;X zSe!v)-PO(~7N-34ff`G`Qh$GMHh4RokgHWW63Y(PGy5GFg?SFZ--x)HpgNuUuC)M* z8kWS@E~Lfu)_a)}iy{z%f-NfuV$cDt2XVy~WjexYU&b_>LdT&4H&mzxJ z%2sc`lC#pNbXyr!Ve1__|GvhhnfpEW>7Ri6VzIrygKPf5q;JJ#aSqu8s|0X=r&=PV z)4HXbp}NIkHJWLA$+*K_ZrA&Lx&VXym(IEt4uSKUC8q`i<0<8u5_F7lK1!*?kqMWg zR>GfZB*BG>fTfDYMCMAB;+%T7=BjI|sxOS@Isp#K_x)rQ`b>gr>RiiGNMQDR?Z60o z+5x8zF*AlxeL8LvCc`=|2!1B<1U@!vUz;f0IMHCg0SzVAor8}}w975(%fH&k>E&#_v!G1vy znVBDf5z4}1A`+hk<|4d64-kT3U8I)i5U+AUF;1(PyuHsOH$sFBXoz~Xs)g_}n7~7| zJZ*aW;eAm7_O!=8K*8WemMzDKjAo{`uN8}1hD{>`j3Lho@x_hzaHONASkhl#xP_Bu}_}(~t>;sb-k|R}MX_P#u z=L6`KU?Zjx4{6WCpobk3Z5&ZyfM_$A!{XI<&r9SY5-kJ1KpVajISm*9VNrpiWp|Ztb&8uljaCM11tUl0Y1KL|&B1L>@9Tc9}Q6`;&<+ zG&a_q3zP+Ci5;BSqWdzksv2P|^1Z}RFgE7}wS}g@Q@(w-_W8tNl`1C$!1+}CA{xpMAG8?J$MNFel~s&K+?F*#(uK@A1cajy0cEd;k2A1}`~9muqJ0x)YY z3cQirB_2?Ng*W=J@U>E*<-HWPJ(ec1l4XVAEcl^>!D_*fS(P7es#%PQv2{^Z)T}g+ zZsR4k+j32-NhK$}67{r~fbejm&+!GgZzG^2er>JX334VbQyzjrP=OXg17O9}jjm|M z^Xn0V2~RRXcnb}~BWNyG5!?dGdlZyfnG!TuD53~7=CMqHjWLK=Wz?}{9XCKt0Eai= z*qk&a?W5`A5q+f>e$Z0^b_2WEYTVF^nZ}5mxkL0N`QSIm43R|kt2rDFr^7j>`oH7Y zaR_FQ;_RVsJ+^hT7U(OzrOjd+q!zz66L)?+mrm8ROut!|>i_0+FyR-Z{CJMiA>j*m zZ2uR_B~!9pOVp_na<{%~Y^~fw@>hx@a$qe+#z|xs1dF- zooq2yRrTo$C~|ymDfQ7-UW;>kd-M6tA~N3FAO{jTgq!`?GdDsX>6d;v?;|Xf2p*Ud zzZ1XEdwYiQN;dj7L=JwJCtVy{5?XVeC z!LVTlb(kDF`LM5N9S#=X`^TT2`uPC}Y&!X-=!lk~k#km__m1KQb6RvyW{NOUkUWB_ zGn0RffTwqM9L0h7=0P3Yudpz!!-+Nbn}wbX=ln_kBVxY^u93pkoW&E`A}UqiMp zi8fzCXQ4{eQr!}Xp7>hfrCRW5ZkIASA0I%j9M>O9NEj+hwxWBrm}ywN*&)uA)e|@o zN2Jh)54-cu6X{7^=#rJYv3P+Rsp{j%DD4Cv*3Us?6y>`DLuJ9J{vbB&Mc4M?q|+Uu z6o>1Q0)#tbdmMaebHcRDJ3lO!YiLvZ1W7lo8Iu0in0i~}D5c`K6Wev$>pyJ#>9cd1 zjwhB*s@u+9gWI(|h8w@HKy-%|uc2hX=NtAgvQl{+@+cUZhaxYC3G19Fx)-4Xx=bf& zREn!GcsU9wUbV6NjUUt7krC_X6CD{TIW0$GdR`s-YSu?thOi|V0%(|)(?%Kv24sT3IoF(RB{;18 zH+>pC7*e2^l*WP~kl0QO0%ETOyWH(!Zp6ej>&Q!#-#^t62K?A&%oaOWXfe!^M~ zoK)3~BB3hKf-z9ES~rhw@>Gy?=cCnn6?Hr928J@nu;e`7qF{MsLt?|%4^-7Nt<+6S zXC9I&lr#8HUETFt16qNB#Cg6!El=!+u~qD+Q4Y&9+(Sid%I-hwF(jhK}#WO z%#dB(eoH?rB?Q2g(Nbl-!YH#D_dnvSFlup<-g`1YUjFk*K2i)fJu zIxmkNhd9rBKgR?-9vCVRj_DXHU8^D(8k<3_hmstZ?l`8SwpKZ`&7}PD zI7#gySF^bEA66wg@up$VI!>~3UUA$FknI#1p!sfo`0ioCjR;uGb-C*6O)_=wH1;R*K4#9=NeT@Zk5^= zhQE6VnRZ4)i=_6+sQx+apiS780U$g&!Q9a~T=)5$7%uBb6IHdI{<0D(*~a4v5<7*c zhXt2e<=ZGW7nFDEMq@4-(Y7K6Fr;N$V2e2yL<_{1pOZlj#SIyZq`0zJ7>Gkbc%`ag z%2JG`lUIYslgK7?Yz{fBhsUy5_j$Es_C9$#JUxM&$cLxXflUp^2toagGS8GZ9fpkTE}pkGpj->ZWul{Ilxig4z7U3Cw3_=0hz(=@AF}66T^r;=9$w+lowLjbo_<) zK4fI(Tdm$$&OI`s(wpQ{KISYFc+7*W$tqBVGE_yet$Syfvh3&Iwcp;7xhUYsKx`O$ zyI%q`WmR2fZW3WMkoMXug)@J3o%!v<@YGlPCkP{m?@2wO-*H-C zj=lEa%;&@vx8$~mI29cdrPqZ(-;!mp26nroXNEXPrr96T$~KTrm#K<0hlvh2hg|US z;4_zA8k->J{mbTgt+yjdvZ%u}Bokc4L02x3mm{Tc8d>-SWE^3gIQ=x!F)bAQ&uYvs z^O0}FMorC4Iu_}NE-qkUSs2G=3TYvk8^Y#TBDdk_LeG;(Sspa|~-Y}Hva`Lf@+7}C2R)016?{P>tHdGc_o+`xllQf)e1 z0Ry=au#l482davx z$ORBmWHOo}G}LX?>5T;f8g1i31P&*}GtWGEZ{&-yR=@%btrdROTg$2E=zrTMcwFVV=eY2SLY4G!r{7 zT5fgR>qbg}YC$?~DbS8!@dl=w86sk4$Ch*6nh7xOfQ2aHvISKuuj6rkD=o0dd1Ft) z=J@wk!Gm52JcKL&(FQ7sJ-1S;_xEGb)aYP+MjO!0*Ao(;IYwp`!TorBGecV%$_c`(4SE|Dveve3E?tDavRsND5usF+q{IijIxS2CUcV=9-)i9kos<}BKsSX8E$==KG8Jv{un z{FlI{&DW!+Af?(+$5GI;)qmRNPVYPQiEgGFQ-}5B4q#nsKx31in5}+;>c`iB zca;u)Rj*X`meRA$QNLU_z_}D$l}E&TtHc0H<#)V;$pmRBIdGIAe%-_fO zn|XR`@ukDE&GN9Kyzy95+dNxpO5veRXOd-AvJDPR6;(pp{heKHbA>bafxNx7N@&8W z^Yq!3YA}n{9Tb;}4-^GHT%?dTPAF`~Uw&)wc~xs$$(kFxwiDnbn`_8I`r5G-u~9h! z_$pY&qCjA-P?YSY%DRma7Af3T~paXf9!Qc zGh`Inw>yXVOQZYm@lunXxU)yR1roO@>~WhdJfBFojSTokc!ZDUDj>-fWS;ZBW3Ac} zkZjih4i`+o_eWbt^Z7huj;n#r{6yKz7(X$6^i7odcG;ptd~{~?d7sm|_ioSL74N*p z%nowzy+6O4c|L-Yk6j*9>ZMKQ7~09V`4Z9^u3B?fj4^)Ia!8cJ>xY&O<*5UdRcvs5 zL2}|snU$4Z{uzwV`i?T}7Vna^4DzXRmm{>z7$xuh>)%@Y`AxWEl~a1Pld#b!$+oPs z9`@eBcfI2P+oMJINCRK&dXll<>*y&NSJ-GSfL-?o(F=X4&CvjFQ)sy@$A zMRR3W6@Sc?;NoaHHY!@T=Y$}RHf8GlU0wZ(e|8VoXp+>Svq2Vg*e+E!)kr=zJ4$Dh z8k}v^j9guui0go%?sR46<+QPd<)wBa>ouK}|JERFPN337i4US+STL6A8;{@NeqEkou!7xf5 zrE1NTA5S?pw>e>(3k_>YQkj2IrIp&tksyFM;kIOW_GTXqGIvMF)l$+3gl|p!=H+>F z+Sc56{V>JZ#v{=|$I_{)No+>ygiZ*~Ni0?Fn_TBuA5p_mBOb_nHT0^uin%pcCf=h$ z#`nk_Zr!E8%b1uTf9W26$MTlHj>Y?9v)6b*&H=A zq?Ds!u&aZPx$*G`T&ag{V;Rdqva@jU(Skb6UimhUW+Y)Xc;@JQO&`2#n`h1!>5k^s&Q*EU;3he@a$>0jc`neZgMpeX!vt&Ex?CF*s zEX{srf8S9Qj6?0xl0^UuPN7k%2OB06l78F{fVX*u3bz;f@7hJE3PJfsjMkXS$_zI~ zmK&bDJ`&X`50p=r6vJOFoX2>r@nrP|i76rj&BLha%ot6R=XpTsarJ2Cz z`QuM#eSUEBjZbrNYS%6}%;$#p`gYcyn7r2XNJOKBd)`E~>G%Gmr@R25Z$3FVhCUN(qKHt0ug1B~{X#i>VSP2xEhZH0kg%T=T^(;+2XAYrw)NY;}5Qbk5a6 z!8^JJ4Et&~VIn*NrIWgiHjWtUAgFX%{c0K;IQ{`P*-G{jo&DxtclJfdV7Ct})fSo$ z_G?alUGQ?vm9Pu5-HtymM;(vRbdYh7GZw)lLT6xP7)F_iHW2vvk9hF7i9=+{Si~hq zR@`+Cr{v~WVoNpoBUo= zMYk$d({RYnYG|%``BLQi8|`7PZ=JVwYxA{|rr+p)@NEwPhH`JXC({j4gBn1HjYBb; zU^48!eeBr7(NzIBDn^Ys;=o`OX#YAzX!%ECJvvWW6o?uQiy6+|{AOf$8%$;e1$iJd zH_SF*X$e~f!TyRdsKcp+z*{{;xz+h@_9Q!RRLb*q0la&Xx1Mzy%tb4a?>TFEIQQ~7 z6aYUx{)jQF_B{gSJ*H_}mmM2{Or0g$cbC2`OUnWR4u&Yz>);sKB9F?IwW*z34v(E` zImtTrqQCHwH`jcT?Ce}9jpiEfM#;4PgfGn#^(j0I4x z^E{r}RyntEYOZ{w7yW=AcA!*?ng*hWj)VhFee1J8wW^AY*Tm86o%&K@n@DysbbR_an97DpS!eP*^ z)84Hnf7gR6yS`n>sgR<-u7;#GcHIn zH#VY?z_rBD{sW^zZ_RQS89QFUbdxArw{dxj#v64zu`;Bcz98@xEpm3T_ z;cH)b$5um&F)9#wFT3Nw`67qKx_i&QuYQ?^$P~S~xjEGZWMNt?qL`zP^Uc7KC*0wA zu!>wA0h#5&yZ((D0IxaUAT;*#fAG_ONT{f-eNDe zpMHMXbLT~Zb5ps@%uF?6@7~`@(Fv-0I5xi2@9ek^`L3#@9-Bs$#59*Z>H4E;tB|Tj zvM$?nl^GvNw*P9%A!ViqZS0T=!q;1x_TG_L1-}0z9P`dQ zZnI8s&BilwBah5{f_L7tn*|v~onDw1KT8Vp{2r3IU7S5z;z|`^J|Er@3Bv3u1IOpM zQc9rhMymjNP|ivcr`+(`A^Xjk@pz^~#m3$+;L&sL66LzUedO=>V%MCC@3m9P9q&d&Wn-xCFRps{ z@?$>8c|uC$M7n1}J>!(IYfmbjAUZ5*Z28=hLgkcHMN-ydv$a~HE^plXD({$OmN`spQ6 zH7b?6ne+{YvXHO8$eAe}Q;@~FdB8$fkm$n;)1LwbO9p95y0X_0oj1;m##miqXxK2V z@p!<%-eD>>s~d3sunFwEO}uvT6mJ3(#bPh&R{BBKvZ|f(j+jJRf{FrtfsfZHLOj$n z#!9ZiKfDp1yL#0Fgoe2v;N6c9$K$C12)M9m+U9I4u|?TLwt;9wIhnXpTc^a#0!DXf zvJ41t+%rM3zMeC}JK9E04KK{!ZnhZ?rdF{W!uhWCYaK-21)naH5qiKWd7JRrSH96- z@t#hQII{^L2wwH7lC5N1=T$^DjUC`(E;hx`y|0Vu))cd8IL?X=)cC}tmPnx<{c*pRh-;= z@f;p0&5bnNi0jn#!8>wRil8eG4{qCnvRXRihxcm^RaB?GJ&NZ9B_Zc{WG zj~xew1`$4RkZK}-B$M1j5*UsObe;>u5I2L^`?Ec-Hc)T<#G0zNs89?^r) z?{o;lC!R-i%sCL}QL^{N_e4Qoyj%Q@_-5@McEMh+d1vUK?}y!Q|jv1p}Bo`cA4Jw%VhK|!snk0qU)t8 zVe>DhWk47WwI;6L0pI|CImxCuq}XwPKW?~a;v4sdq20WZX0$9#lONKdM5&31)TgCaVmkKu=dR=$Aka}%Cm@yF3^PuW7MB_M9 zQD~e4@L2iqh~d!-d?7*L4FPx+}@I4YldM>x{tsiNQf6 zc=g5G{G1VaABd5Og9ye%YDPNMRXV5G5S%E(;XrQWV27aY7@O2F47nk`zCR#%&B_(G zh0ou99-kjsYq^z6AOw7+yp_^$BmrCKY-gtV;0mj}u}5CzK?-65*&GA_$E(s#>ZcdTHM#4~xNlw@lNw6$i zjQ3ONrV#b>f59zhcZ&^ihCsBrvaMjLv_b0n48Z5Rj(7JRYBMS+$;frnB zVPMM+m&>cS?j>b52n%LDXEnfrWox=&X{n%bvSE%S>Zel4=65-h>oxkE39rG3QDtWH z?z)w;tiZemhai1DaUOj8kd&8Tos>jI%fR-swfc(7^HYZoL*b$@TTe&4rs31^;&HQE zI$x{)iW{_?*>G$VnG-&)UgaEEwDd43kwkj}OSv4ERwJJrJ7lh=$HXb_S@W-{>Qx4u z-(W(GqwfydwEJJaY?ZaG-VY#gFT>?z4%Xfqn_Ae*T1eRvc>3P)5HA}p+_8iLc<1|f zi2}3fpGFn~Vnsku5kwHf>jh^=0p8f)C}IvW#B4S?t2Vj)moX2X+4!0P{B1W~+9YiO z-^TUGdadUF@m>G28GO^iG>iTFFj)xD`zR1X)S0u9Da!DoIU-6%kN7O^To_Lo;Q1Cz zxLVa{sKt`x4G_^UMu z$rn9}b}G^szsHGCi`z6-XfC>lhtZiz1`(9mg%cJCH#=fQJngMxU(Kaj-9I_o<_{j! z+{_O&gRSNW7Y^6S@{V;5?v@E$sGGC-4!NzH(!;j0nYeRhc?^KvXXq2G;$$}QO^fUI z0T(^b%^b*sd-84;8Dmh2C0t*!JbKJ#Zg+EdLiqetMfn2vI|xxOAA7J7W?F4xTrHwP zp43b6D!%brP0az4%qiWny%eHNbT^S6od@=5`1rPJ6Qh=cvJFw_Tuf1KIm-li(i!_bKq1)qJ$7>;dQ7kwxO7CV^a+GJl;@sV?yWSl@(*~pcbH6lAnz_fPZS_xC_nF~J()3WF!Gjn z`u*`Vy3&xPI4q@3!4MQb9NRQ@Xo&~;4(5Fy=y}*V$_?y&(7(wYP6Xcp;<%-Ba=&Xx z+?l=rNkF#0-5`#|^0>ng1KP)e-WQxZqDBaeXC9Wpmd62ShzRgQe>3hr$2$P9k@?#7 z)YTjF<&DXDVtv}RNwMrH2Utcd#;Kuq4CAJ|*kD1Vz+{7>CSHakhkh z^We95U04Xka@9boGnT4iKw|1(#>p-*z=o94s}qx$OiE2DEa%ZodOlmvKDd*-o@2uJ zKF-5$ApjnNN;xC*6h(O)pz}X22WQl|!I~I7-30-5#Scj=O0@{^34PFQj2MF$3pC_z zNL3VLe2wcV>yfCf#$J$28mQVnRIiKG+JLo4AfE|IRE2A~rOwy(Yu*%F+HCmhmgTTy>- zCz{;XoKrdnV^~+v0H85rtNe-ab=&C4j!=spabYtS*53K**CXjKBp6gLUVkm~`Bm?y zM0-qo;6HC6YMx?aciD9~I0aZ7L{}RN_DS=>>z8X`*UIUl8alpY-L=J*C&4h(z<^rH zX}am2_|`Nk_YB;$GHgSD1k+!a%5=^Ol}0NFx{=UW5fC*s1}%{!8*$%oxjnT!9@q?& zvw57C3p6l^#ETNDXrI0v5SdDBm^5~VIUJDoC(Jmjk4q8o)S?xIED*T7ScB|jymQYl zgE?%uUed8=rpII!%`iN_{_fY);s%464Y_48OZ1S|m<|j+miZEZ#f0F#gtjq5b5%E| zD-75iD@q{W<@(?!BEx~)9BFo6@4xDQ#643g2k5dp$fIfxw^+=f!ZFHaK~T%kNl^o0 zCqK?N19p|f5kgjTn05V|b^HU_;cq)F99n1Pd!J7Ke-rxK>*304)LvM;sO~xd?UaeNs=}){$Oil9rPS42W5Y+hsDU14o;{0 zNB1-*oNch6DPLv|joNda4Yo6T>G>l!lxm3(DCD8Y%~>h421LpP!sF$ju{C2?r`b%_&qj9nRW621mutMu7D+BR+3v^1N8 z6DD(MUKpEcC%lt_Fb#tZl>pe#$jD$7Rlvqf;AB2oE!Jg#Q#|b~&q5xW&G1uxy}*f; zSLv!O4EP1kN?`oGD!5<}H(%Ye-1c#ja+a`|nbT<-Dwz#BdDO`fW?eO~B^)sYI!*HW zgF*&u=_(xMV&?SN*(^wx+t0i$t2jIbfHu*kI(U9)`LdPsiXWAJAWkbC(^TJ7w*Y`f z;`N)du+r&Aw5U^IU0#=}*zkn>@?>rwDJx;Zyiap3#n=KRPN~=x4}0;1XE5<#LfwGm zAru4>4;0Pl>gPXqV&x>o_-gTNBpT+vn!Xg*rE3#ncbe-EG&J>2%3@>ML?qqt6h~UR zV@4QGoU)vvk4vkMZ{CpDR*+M4sxv70Dc z`JuwH+N8he)pO&jXB5u?*A{fWvjJA5 zCB#KvR2K^Z7MOrwtFB>zEeCP}1WZBodYFE2rv=foEgwT7E#>d{o+g!2`moL7%#rXB zh=Z{$s=DHYq7B1b+afrE0()%h);Uy6XRKSy@T#%_uq`W2tw3P^R;r8Xwgwd)Fli(% zbjot-M2lPx&hnMtuZQoqMp$Tk#H_Up%pHcI^PGC(#lPCNcuP2g3iAJ&z+cR2Z$ti- zuU$K4AG%Cc5Co+gl$u*T^A@w2&GG5II-?Ey6EPU_1-Qr)?Cqc=R+q(8YRgvTz`}+o zB+k&*GMEiAsE>$xK@5H78nG!q-#zNGY#e*mN;>UCYR23^#ZFrW7+WVV-@-T=(<}f1 z53)KN<60irAOt4+=Xul{9{LvH`^k>G?Wt`%eD>*`WxV_uxnl~Mgd9OVF2yOL8BHj4+0mqG%>~_~nCWyf^r^@Riyow%?AJKJDXw}gO8nK&WQdx{ ze1{F$IB5+18H|~OFbR@zNRK3rsA2j`!r;NlW7>!PE{-J#C0_c7ge2l(w_C-;h@yKL zB8*`Sj}6+Ke^O1tlbcwmFER?KE?_0uEhofOD>Acc%a{>wJi#2{Imb9oGC|fY%a8pK z)6&egK{)O!K5Vzh53Y+%aA>nGuv8bE${Sy+>2kr4e1J%Wi%H(m==qUP@uHIgo&oQ{ zWiW$A0T>Mph$NgN*(7n|V}>W_Kp%L6-vz*XH2!p>`sjxG^Vh)luZ174pMOj!te|~TpU%Kb$uiTFA|y_5QlXI&N6+Z?Zl;2Y|I)q zVWJ!mHbDZ|BoziBmbj>H*|OeJI>d&ez3x&>7+2f2b7P2gMrn(K;>buz?W7dO<~q$Z ztW?EH_CjS`{8Ij++#XkqqEC zUvc@hBt74SzmM1+%7r2%4}5Za4{Kt%F8${fk4M9EozaByp@PKYq#2dHUb)nm)VC(3 z(P%RO?2AlB>`qL|47Fo6rkiu1jub<>koHNyt90Smuxxl*npEnVX~d8|<{bnkS}-lo zfoxK$rms0HR@NO3s438tgGp#vEWkcZ}z&AN8Q&+~*_{2Aphn2FG zC}*M|3f>+#hQ@Zd9gYQo#FF`wmVSSo#K0dDX ztHTr)sW{9*U|AEm*lA{DNI5gzlIez4Y~zJwWmc9@@UC8CxO4<}zLc53HHzzM^D8{h z2||q(INo-dKZkL=VatE9ZkuQZfTZA3=h>HE{Q~Be2o|AkOUp~sp*6_1x{kwabC#mp z4joiw<_JoSL9|kOlSn1#b=w%E6-RFEzr0CQY_?Q=en~qL5GZFgZ9_g!2y1ikCdm?m zQEXP7m<$u?j2cPh%CL$H z+D;H;Yc|f?5fjfKy7Wg<4OZP2mr^VUjbP6vzB+O!NY<-lrKZ0APR2PwSHF2XUsul5 zP|t)lCJAo&<0*M(?F}Omr9l!$`~OmsR9o~RR;Fc{9z%^ZN9TZ&hN?z5q9}Hg9U3uQ zb5eA|h#BI^O@|QDX>SMRFdLhcwlAnM_IjftUOv6ZXK6~C)p!YQ`^M64rdiu9dW_Ub z8i-rw$jS-jzY8o^OP|ZGX@)GD{*(?aWHhxhGA^xBU;sm#%Tp)2Um7h6oV=mip<-&O z-crgnmGd$iSmn@j=eJjC>+aS)5B=tpmD_!J$-4DwDg5SMUNUN;WP2Xzw~-Q2WxE8A zzH!^@-hvC;oxLx2X0%pptX-IYs&M7UFXceUbCilS?CYZibst-Qu9QC1u7b$j!L~ON z1?+o6j>J%{AADxD)ZgzI$Awf%wO4_eqn$+_2=!;}KEFF4aj%_gW-mIZ;SL9Kal;Hh z+i6N1-v@u{&@4$-YO1w&(}mkW8v(Zo2z4+HYe z|5iLxc4JQ^gJ^^+Q*#91pCW7k$l~*_BQoOzLC4RRZQN z)sk5h3F)sk3vX?{YlK8Y?uF>g$+F$xH3ZF_n7Xr(rcb ze8psX-CSM{%xVkebdFHz;`|Isq-0ALz6yK}#XWDpQHAt>tE{TwCuJQ0vXx@X91PQP z(g*DR?lMwKP+$6;OJ5xF;$wFp<>2ve5!+t38_O0`Umt8sMso&}XKPAoTL&@^6zxap zy21_z4K0PI__izxKTNfHKlH4yD%l|ky5(?an6?f!q*LNpm^5zO8+wsL)kI^Nsc0?? zO1Xd;9jcW!>eb?!Q9-J~>OfFY=s1m^534PjQT zj?7ECO4t^TO_t9&B|6U07E{R4@yHS-CpCF&bJ0)_2PMsOXD=Q@h{tysZ3fyWZi#V- zE^y#og+Jg|ub}nyyhNdF+IuFbY4=!(|Og)1!k_5|lm#Zd%sF*nro1?R`H3l!)T@B5V z%dcm1;Udba4~2CDGB9jYPZI!USK&+;=2l~zpDo8mns$L~voD?VcTN!*imWaR#XDX3!SYyE-@~c)w9EaK#~f_7rww`GA>ItnvyCQpse9(8|)l3 z_<-Bggj#MbQb(He`8MG`)nE>*$xKKmSntaS*8`ejb5dqz&fk96IHc`o^0fgiRYi#c010=p)e(NXR9Y*}zc95b0?G9_MmN{jb38k0N? zE1?-)^E0+#q2DXCm&%(a7+6Y&X+k4LuvsPu_zRYP;zc6%u2?hHF@yKs#(pRPdZ2GQ zKr5JF9xKM+@A$^SASs+j%HVGE_9CtIHm0jT!o`F*hG}Is!a{A-jS9|*22>QqfTfn^!Bxsw2$3gIMJKv&+|#B0(=qm)Ci59m{c$lD!$okg zD;g`I`^C%m!fV&uQIe>4oS&~pH(yVtTVW!6wb`O7Deo*8nyuBxjNw+=9}h1Hc* zg-P3b5cW^ zYxU(jGbfu=ysNf!EN;dwwZUMsjGW#gD2oEP9}l)XO~en)o0$q`8{&FTHVWt%$=)6S7xROuA(e>nlNOUA3C1;k%+}^1k2; zz3nxn#Rd&vl*Tm+x#Aa5rE}x76vbburPGJfL>=vC#2FaKj(~-(5569dBp?F7fp}IaV{3{w^Js zz^RGpV%K14lFlS?pK+Km4L2=~S;82kz}I#-%#3Mk=vFQy$!?q?XG|i@kw}l)rQut1 zlr&fp6Vrl#a14rR$+JB#+2TzZY|Ylm4C@4aRjWqPF|>GJpqlIjCCnC43>HtNIjRNTvK4*oLPJ z)0}XkC}LEqWFX2<+e$@9K;0ZIett0*MHD586J3y8g%RxOq3SRRmIt>7rs~0zm)Y9X zlyziWv$XWY@+YdHc~*LSx*hrN7LPG&ZkT@{4U{|byq}5NjR_b*+Ykd&hQb)rQm-D_ z^R;Ka%#c$b3CyJoTq)Nf)6O{pK4u(E<(Gy6_ms>}{*K4A>f(Y<)2-75w$Wr0&s{E9 z0n6RYqFS_R!{|TGUBrrIG8`|fs#AEYT4Zk9LiP4`ynz)Zl8KGx>oyVfjnH0p`vzMW zDc7DxxkZP3iPZD<;1-NB#@{eEW|%>NIk5Aa6RTU&VKGqLlX)|D*vjDpJKgDsQd7@4 ze_Ts;H`MW07(8ow=~OmVvkJ{j^Tr^{C8={fjMdgr3m1??3Rmq%nw-5YTeftXK zphomY7qPe=Xmxj=D4DSJy*Xz3XG&B@)5gAWP)JCh$RRyfO;xSV@;W0;m=pJ<$2R23 zB|fa26X0&{JYVGLgyPsq;#*2!bIpQ$xegke42ZGK7D~QT z?kif+0UzQ!c=OXwPHC7|WRThllf^GkZe~oAMZBi&5KO86gJ_a0iNOb8jH${RCCtRA zrbZg0mCK|-C9q!lVV*G3*yqIwQyaxGu1Ou?r4yJKG8R^gRdXXXOpi_aVOc=KII}L(puAv7yfRJ(@K!oqDdkj#5@Ui)x~`v~ zMp}lO80FsAWy$X}un?jlB3uNaS@vkz*6OuXYATS6$HoV_BogLuWL+o&OUqMG!&#gMqRuP{-=w%b$iSp zbQ=LEVQkjfL>CfAw%l1Ybc$8`R#7=fHLie`{l%5jEkDQYsTq?}>PLIt|bRHv;;A1yy|J^;rOX36r#`kP(& z>#OHKCvDrX5t-$~1*#EdTo@vanCGqR{NIKF&+t%kcRaBjs6PCd6Q3M3!)>azgtIQj z#l?lswbI1=G2IkG6>1`W&{q<;oAPeinxCuZu-^iWcb z0*RV}jiqa_qPz zX<>trinVbf^iF(oj5wz!c61t4{mb>V^yBj3b6{I)IC zU}~0h1(=I2mKezo8rm#@HsQ`aYuXpDTgXZ9g`5K2$`Q1G93GA z1vG3Fq%)GfkrINNy;K!P?oA)+LFlTT4RnXAIbN)&g-|)OG*jU+6WhB;cGwZkoq`ms z)$yU4EV!!LOq6>4fQ6MutJyH{xG8gEt0T`yW}HwKYR8Grk;5NA(&n&EE?3IIgHm&t zfa#B}+(*gk(AGq=kiSwE%S49_Kzcx?m0|CjJyEYtef?XhmUd;39&3MGft0~WIknkz z#jqhVQ_fU6myQMo($_8JWUW}KR8>l<_%tk#&DD@K46}c{=;WnkCV^SzcDOd$EX{eG zrL@HTn;JH=QZW>fE8sxQwCzhTEp>LHs!PR@L*Nzs!1Jpg*2muw=|n1R%h-L^e9gmW zeq4nOh5y{IJsP#)+?L_7-fuJBL4{BuR&IlP$Z+-?vsiBGiXPfAyVPN$E8Tbl3L6Tu zZgfXKeCE_+uDy0&z(dB6JJm;=B-fNif1=E=|}D1JR*6)B35l z3`1L8IZA2e+}qdomjKt^!^0Ks{^++UpmB^V*Cz1N2)YkM4AD1NAGRRW7ZjVD&+zc= zD+L!_^GUIH>q|S|gSaeWq~oZe0D<_xJPlJ6HX-N97+;Ta@PHKoV5g7CE37s}Lrc!< zw@(F6e>9D`#Nq3ikLHce{4b1jwQFjVTJ$E@m1Z-Ll&Ui#iK61L+e00?(sFo~*J_89 zsP;+GyQZ>jJEG{|y)@HIp&f{~KH@knj6)YX=x@4Cy&mam!K3YK>$}2njYYQbq)Iti zVf~1~(8-EtLhEdNKK0fGw(FKSQ7ek$*6Thy^6aI}@vTK~gNrVF;-Obpsw;1G2Jhx> z^O3T{2*F7!>@FN!M?9gwQm%#3!RBBvCv#-4s{t%_$3~WC<^3l((wpqVS6*aUaVk1cRy}#!C8yLZ85?pafA5yapj&d4T7NZZ zl}Op>X?M^$!puI+va9l99^2-O-8C(Co1;vAUeU@och310sNwfn3%HMb*;z z=_#FD9Ge=BUdPN_3;-w~w{O4ZWIbwT?J5y8lOj7}Qk**HhlZ*!*o@ zU3Emf>c}{zX+?%a$`VNIcDS`7c<|qQxzGjL1PW#b012|dT#7INcE8eFdH}*KrfkL$ z#*r+bTp}JZAMq$&dEI|9fvC%f0HCWuxX79}OA=rUm!zo{$YjZ2P|9*F!r_?&#_r^o z7X5V5sCvHY&4Ze&e#|dIK^%DC*e<`{piwlcZz4uJlX=dsTOZxFK~foCJ8aK#Gdz5#O*ss!?kst08RZiv9;VYY#P#5H zR8%U*HpHe!Y_g5e&WTF|;fgcVUCE*8Bet{egw3`jpgjCke@A?h>Y15+N6 zDXnetYsI5QNKM~Ad0BcDb4*}Dg5P^1Q75*4wRSfgwx$E3R!7({j$Pl@&pDRqD zFk@rPxbG!@R&R6_^OdiaHUKoe4L}pz_kjT?t+6$r%@3YdqZJ;_@WdqAqXf_RZ0D`O z?g?ctG|2wpYh!PDg5dSQ6O{U!G7Nx|_8iME1ik~iad_R0@dIqSuaz zY!bGMQ4MlzI5l_Q-?Nc+w44XSZPlQ^UcYkj^;Q5{*7%=m_n3Aq8w7BybF7c&LIDI20IaSoMGX_j1WPxT&OHLseLJfw05O9I z`I%^ffa3%ZUg>6$FAea%8ZIg0C|a_T5%U6*9#j*EQ7{3#5*EN}g{d)d>&F=l>B;Nv zY6Md=hmd6g<~WI9Ap_wXL}95-Tg~V<&-eS0Ayl3KUW3Y zH?6dC1_dGPgj5QMiGSc@8u%=?%*?3P?YB8|i8}n)FRC<*@Up1$k++%YmW%B@`_y~< z_^yLP2RmoPLi<#yJ>vSGKL5_*y@U!*>KYt9iVv@H8Cj?>W*x?6Q z*a^<+Hc~;Is!mKYm%h?Wrr0VjQ-8p}|$2QO?=F9{P(%1R{ zE}SMic0iY!^ejB8<;BVvxlbow^Hc4?$!XLIH66R2GW>m*AHo0s5B;$J^Z)gJQ2)n& z===Sj{$oEnzWgVzgcu-z5WK!x>I;7`t}GUdbZVbx5|KtqG(_tpiVMB^xcLnNKmdX7 zOl^1XN=7JFC&w};=7|xQ047A$CIS*G^r|&<0pek9T|(&y3;>LsWdZ@zW3&M*>ANEg zyGH9J$iia}_Y<}tj6h6G05d>cWy{fR0F0UJuo`g)NRDZvvt$$;qTE6PCawEy*lbv@ z52M4;n5TnYGRzdFw$|j@3Vk|$@~3*J01F5!0YU}BgvM!1g&3rH;Rok`C$B6FP1 zXbb0=#6vsq^_*H^jFGfvWe^OJ1xq%WFhJ*?X=U$lv@JQ?`BEeT8UB2JDh1JAmGboi zHrcXFBy1MWkF;P4hVrSQ2x<^03qT;>3xm%_51M~qHOg3!A9|GMSuk)T{pD>YDl3$m zAhaJlX+9vnvJADsxm!czE`eCdLH(f|xNn;Uz=y1&VN@^Nb*Oz^uv~bnn$qULuyv}I z+k4D}$b_Q)p9>R8brAzte)i=~a>(*S8NvA+u2Q_)&RUjX2=o>4SwlX)7Hoa4@XWwb zro%|JiHX||!Rn^;ucH5eOZhLKfP)}# zAY~JyHX!;dxrr4!-F05^YPHpRk+q3!g_+qd3n$Xh$yP+0FR@wd((9+@y5*2pKQDA_ zKLpYa(ij;_V`6(6Ky6GGz91hu27&uww{y$}Vw_aR^aPeM2p|9i^lEjR0JkKh zW074C99_(~Ap-ytz*GRh1P;qKavfsUHkCK33HxMqNH_}3Z14aw5+FYb7=ZwAHnfuC zc4@Dp49=48r_9xf#j6`bsa$x-%ru?WHmKp)a{u-V&u8jEmE868+d6vX$bq42c(WjE zZx1LT0?nQ65aE>ArOXpGr6gH^+ceh}T;Z|y`NzxNy(G;g>3fzOnkjxC!gm*-0!QX5 z)MkAG!Wj8JGv{Dz^h{XhO#_)#glNt&vy0_us6?(X2C}XAisH7!ql1c+PNzhyP5{6L z%_heVeziQ%pPm`A;qk)Qom--7oKJtS(aZ(sEfae6NnJID z?=*%_l^BBJK{5J#Q8Ao;T@(m@ky1VC>KQ6Aq?0?n>pP8lJ0&I3+Wh5cthtG;D$XKK z1Z=yBbs3eC9m3pS=Srz!bGFXNg}BYZChmFZQi15582WCB+iiz}(uA)APPJRKaSl5_ zwi1K?)VB_6hh|Q)j1j%3aZXegt`yPe^-#acXejeJ@whQM*v59A`f8ePEl3zu zU7=LO6r-Urww$)o!UB*m2=g=>`dA{Q*BuWF6lgI`LDkdDkhN%97RgwUD=WhJgt6IM z(VSDCUQW2y4&!86c0MbJZGHOt&mb`W=Uf99Kh2K&j9TV#wJvs@o@960V~)tx64jf(&i9rotUG{FJ6wk2tZR_2ow12|4wMK@JjL}R z>_6C}zpw2}3kiV01peW6IPFX?J^1A%2`y17DGfQH6O~i2(5JoHVxRv4KmZ7!RmN=z zA~Q?HXLX%ezQQhC|Hj0wjAjgfy$gbeh`Zk*2pAv+5raSg10V(lpz9hMQj}8oY;RWT zz60J9G5Hc<1u}pbU}As(Jms?yZ^&#;CT5akVh|f+0{5=Umd;RGK9eR0I7EpE%YP4U zvE`-QoLiHW)?jV9622M`h9RD79DoC4?;(Kg8a7xYE2n9yMJJiMUMqwL@1Eh>#o18p zZS$qlh%&5bhX{|{2?Qf9M*`nGgOPHE0s+xzZ?Nqy14e|us@sg^PZb(bCwW(4> zI<19u7N0(b+c)W{`-p|QBXsHXKIKY(@p=SIC3-MZDqmp>p=*F-Z5A=)^lH+eZZNfKSnyC3Z+gPCYJLg zbyAWEnC-5Y5#Q*Ke6ArTUuppOy7`B-*)7&(XXq%Q6 zbu^6lCW6(=A6Jw<&`CS;%@XriPcU|wsctgQ(YKxQ7;(P)$BRJbSdeN@MMT$3mu-Jq z77oJfewtd6cVviC>bcgO7YHZVi^zJVbJqEsUV23-S3ePKScNeASm2qdLau&TL!$@F z>2zUY8`Q`;T@>uEmuig+o{a~_LU&5K*+D_Hyj zbM~kY^oJbVJN`+{+vuvy2b!wVKE>al3cb7jAVAn0OON8ZMulRNvjv%`N!PL7S2Cw! ztMQVF*_u6J-4z}(9<&%WdGRP@cQe8FR(F*Q1L>6K->}#QS8!GlG-KFKo%~tre075@ z@|u%=t4@$1;qbH^u)}o2V`dtMa8_yk(gq7K2m}TNhb{g1 zBAZ;XF;hb$U)4-7+d|%f#l?GNh7UP zT{_D_0Kfn-(9#d}Mv+mSW(3K)P@Oc11CXb#Gl9=h*;p7;6Qqtz37hFbdm;eD029Cj zgiKdQ(Ifq!wVG-H+hfBHr_#0(vcPe`bVk{1GOXNb0Aj}WW5ajLtj3aGx%E$24O=-N zFc1hsjgp7=fNLGUuPP?ARJ{!3J=QS>~P< z=zIZ&J%mU>mtwBD=4C1fEsh197NG6v`5W69h2L?h&bh6sns4qggBTu8*Tty55Uc== zPCCapy4Gz2+d~(*g3K}d4XEWdND`zuFgC+#4BDy#uVlzEIp<_cL(KFfsuO8zC^ZPM z&~oDAv&VZpj{0o}0^(S!rrI+!9t|noDD&rTnW;j%GC|sIoS6BdZNSPI@9b58d4^g` z;U|NMjVN;!#5Q`0C3%jHK_>8 zL|Rh|Ef!Tw009ha{hux^2`e*CyN_il1%MdD6DTaM2d+J_DYcC$4>al?*=osmb&bC~ zo9D5~FWfD4!Ca~zbbTuJ4mZ{vgE_yv6;qQHo-Ldw1{M$ljGaIEm+ch#qlx8~s0Sz- zuwgMEEzz#06q~88F^K-j5CFzMJUk86i@y}IfnGF>AA+#Uf!1I)n2jGv50K|oMx93@Wy801a|fdB(!&beD*RCpb90|}>Q zu43$Y1!-RIdq~^HOPZ(wIQ%u=MbYd0*+bTltm&2!9!%>j8~vncu;7qJLU6I1n~+au zBOLXg#Wid5%X&AJUA4jp#hR970a=Z?H;-!!b1s}oCUH(*IFBqb_ag$!TK*oGrz&$V zKeBQETUpYt_WB(ApZ(0z#sT(~uUd6r#m_csY%XF(WaOo)SQt~m-IUDV&sxsv1Q(h{ zXElLCnL+G4p^r+YH3U2!L=U^KbG2<6n+KpksRXQ#R}97|xW~8V0-$j!;BqYeP5NI+ zlx;kiYnRGFnXT-m6QqamlR(&F-yt-)lsA9tnQ-(jCQLM)-BV)j*y2*q!ghPf%}`1x z*{@R$Q6JBtcyoFzU2LHEFjjMpQ(qjd_e(iY4jhsb*i>Cwtu`qxZQpxUq5Yp7lR@Yp zTT5&}q2T!17N8RY_UZWar8JdZh7|TLqJ!hqQVL6aNyZ>qp8#R69NsOLk)};pF%NEv zER1Q$ZQE+4zpR4P>4~7Mia{fYzPE!Ryy_U)_VNG@=ckRb+ z^Lc>4Ow1r$o_0IpzuQiiesWEwM2r7h+@D{&*ZH~I@cSMu`m`=P5!(S&rjwIAvn(^G z&JpOe-?pE;OQZf+D2>cmWBa`q2A_tpbYTBBF zwqa(4u1qp_LEBg0ngsO=g@LfZ2?ii2Dh0T3Iv@aLe^jO zE~hI$8?n)yW$AXVuKS&#K?EC2HTC*SJ3a72FA2ZhDl;6LTLtRO%1!anJxu?Sy7*qD z2Wv~i)vD}qcvro7(K(DPKi*%Njg=+bRK@*r8|2fzMruppVn*L3rP`w42}{(vw7=e|JNq3=K~gOkGQ!c(ISCl*<%1377Qh4o7y-0|3K-!(t@rx{GcIGWiASNa-))>46m*|H3UF>m)As!$2F}8cBu5!{& zFLg^!bgg1`EtfUyVrn(Ge+ai4KDCYgn2O;u?)(zZ#m^qCEF62QwO)nY&dt(i}EzYWK|EBbYtI z%re2m_UX=d&UvgRhqpIGvDcWmHB6^NBM!`lq4*l%GI#HeV_^fF>t78q;7I^??o>`N zqXT_dU#j3RU>k<*87k}2J#jOMy6ajDN~C90dH4iTlJ8Um2P>Q%rD#?)!X%xma#E<= zQuloIg!8pdkger%)5zk2BNXfL4Nsk2I2(L40p%}9Bq1_oOEvqxAqD~y93p{^-p!)u z8ZEhigt)Gc(72=-JQbKUpS*h`h&cjm4ybmmb!fCBid%|yY@h zap^^gl1NLhOL0DfP{PU1urOPFr-bRuCcAW_16{n`+E?(og@V_duOjcqYHxiS%lJc@ zlM>+~BNSznOndiD`OAf1$llDoJup2zQYsRRt|XqlnxPF;7MM<_IsYr_)QN=STcv@E z11qax*x>ITnJ~JsNanQhoQd$DGs~S54d2~q9@aM5IIT)>n7Q8=DU1a{^8ut+^tpA= zDSeFSbJGgbNlme#>Zn@URzPqWaAZhYm(gKq@O;^x{XhbPUY$erw(6AxP;u4!YBpVWD_P zXq%7`Ad$7C=BqmyfN*olt52j=o()^OcEOqfi6%AI&d0Qiiw;ANfa?^KWYO((-A*wI zBzSX={=7}k<^3*G{Z})^jJ8zKmWI&mWYd;i0AND??O#W9lh_5V z%Ayv8LMA36fQfiz+vc#+6TU!qmN2TR6fM%Ma8paqCUJWQGBGX})H`~!OK)403uK;mCj=$~Fy>{)QIv>HvI3sh+ zpVy#liHT5camh4V!8Dc67U43~I())XdIF5f01Kn$sJZyQuvOQjDYPLaZ;cJar_s%o z41MM)=?Y^RZW{(G>mmgxA)FI&nhsH4SLOt^?kq6^=rna&moU86)au&i&;tZFTG!Yf zCblSA(6VW`qPE+3ty9=~lxkE_MsvzcI75oE=Ffl>6r3ZIznFxk&i)*z}OpFXM zm{Z8#nqmU&Uk3wJ#o|J%>_mwF;sr+<|=iU-b|DPh`RVl_3-|_B~YR z416HI;3#L0&EC;iy+k@%&LK3Z@S2vRb}oFfBuojQ=D`ytqh4y&kHrbW-FGgCuHx&) zn55_dCcCw*DAyA0&0#|!hC|`4akaH-5#gVE^l%dvtNS9EDLF-_4;f?DicU&ic9$Jo zDovOK#eI{LhzE?E;}SagX|cJ30&vvUUvy4}t*(00!+e zvlvgJ;kWvmzd*W=CS~no+7`79x@%7cNyOXY+R}XKfjgTQV*&sQt2MYbPXsUsKuBEC z%BBV)p=xI}k-LvlSq%rDp|tH8C^M?dznB3zq56>@*w2yvq(37DN3$iRJ03BsqNn&BK@MGF=~$>Uju3! zvtHuNJsQsE&L^dgqlcK!BTp2hCpg9=xEMS@*|9m+9@={3k)Febb`T; zD5L}D!2OH;>&ci?Ju%$vuC34pwdP3&b*`h@xe6RS1z6P6U4 zEEuas1qbf20C#^FVlN~gJ?iVK{%kLwOo(}KCQI<@u+qdiU1cW7{JRlGOev)SCgM_$ z36VP_EkC8ulL&oy_fap|E@iz_B7o!J^IvIoE|okrn+-C+laIBeoE~D zmN$$ntMVIbw=K;?p*u6`I!b7DnYzerI35grJYA`Jrv+q)?bW7PVyvfnakLXIRcgcP zw?#W#Iow>rGu?~7;7BU08Y=n-L1$Il(2T#BC-Hae)In_gt*r z3g))-buPf6HpYlswf*(a=0PKHrj}-Ub>MVO25L(pKY6fUtnwie@k8Ey-T~1`;^oJv znM|pz7mcR9Wj=EEYVbt0viDIHgzc)-Hf_GOf1(1H4F(Uog?Lt@#jAmnRai|u%X~rF z*vdGwAHEs7NsVCL`yS%;O@=*YjuAHg?6)bwoVN7R4}w=#y>tc>7)|HO)z#W!{V)XJ zlo8qOId7SkG4c^JbuvBjyCyWk;U0_6kNY+XzH!_rljlIg~ovmDya z5M!JFQRGxS?cOqoh(Qbx6W{;Ee8!HH+y-GxwS}pnjY?z3NTeo1S!T>@VM`rZ`FQhe zu_Z7D6ER^6*;=>h{AOTcCa{J(6Jx&A-Eh;Kbo>GXTTYEJXcGX~_6K*#Ngp*RS-|;s zcwTepawUqDNQNf{F6(dW@813R8^{dwo^zLI?&yJCPSDi6<9N`cu~rfAaN75o$Lfz) z*}J#f6N-vRmsw}cs3LxFR}=-%A2r7gLo|Yb5hEAEBahuYDDBv6D;+GB#3o(e#HE_c zA|wtZbq<EH8FHE!^D(qAHRN9X+L4T>TyAc2;lkwqN^`{9FUUDrHHrc&ka_=v#an!5co zK96%qi!0YSf8adaH?1#jsn6jy$$BZ&6NEF{%2Qa4w)?zyc~CdX7bi-h1UFnZlugwc zE&asDefij|-$p>ly;e2+$S?3jcKNLv2+!R4{n;$kp2=J@tY$Oa38O-b{1hdxeS|o- zzd5t#{kv?sIkdI20R)fPQz{kkXPJv^w6$1n=KFba=}_7jmF=dgD*YcZ=t4lLaL?QQ zN7q#;=8i{2t?Oej6JujDYr{%Z%&qF8if8t5`7Mk%t@g|tEaWdX?&D4{>NwJfb5!x- zk#ze(uS;6HbelMC5Z9r=96H;d-LW`SHPxQ&ix(`$Y4J2I={-YroaQ6}N|ECF`^yU!d+Cm1<YY;2XC_$aBFsu~k7;Z_Vn zfsFG+Z^6s2dSoBGuk9)^nMBesOWQ4>L{hC<4Jn(NXgEwVq_a6ZQAOKYRQLaE-esF-#5J?0MQi=)1=JMw=qP{evaCK}L)xe?qE*i_P zESETE=9aa!XTO{)jp&o-6(0M77k4jl`SSZ2(2Q$F$l`EN6tWercMNviGv`BdP3z4i z1*q8`GCE7IUEKILXK^;fMUgHALL!smK#`jfm#v6(!wgU?`@k_~*xcqBssQ{19VfSj zhG}&vvOzBG3xow2mSKx1*q(+e%tk@Bos-%ovs=y|%E+d&GjitqoudWet*b9u+Xsf? zN)0~2eFTJC(XLK(!<;bnuzpE&{^CTI1B@9O)t=eSD1xW$7f-1eiH@IE^yPaISXR2x z)`@>SC<8!z>UDWm$GPP_Bsmx63Q>;WP?Usqw;s~w9vBkFaynR0UbZj>(abmquGSa_ z|EIg#khk9*@cMRZE29_)y5$3D;Yv@TEfcTsUgEsumW&GgoVqS_W{KXC9tQP z8}&L*$5oDb(WQ-G=NWTDsotbjl!^@oT6n7U&Pm*tV&<8LQl`3(D-m$x>4zNVOQW-c zrO3H$HF$u>vq@5DwRQ5459Jw18>@pe7H)-3Y)|&grF32 zxH9dcrdN=G!Rv$%vdw2xMTe}KB)B>PM|TsLSf;e*PBbt!n_h0$mKuA<CTPc!Ot|~9FswS#Gaq1 zLpd^P+E{vma>UiNE-`lmdl^+9;pQd_RRA1EUCg*Req*%>3=9SV2&q-i%25N2nFm@& z#r+E%EL#G=M=%p@Eo}z+A2HiYzblEX*tyOHCZ-7l##V2o@Uo#p=!?yzdhG?o1p&H| zt6NpPm7}%Tq%(qy{Vq1xYN&G(rdKCaBAqZB&t<&#x)}%rpue`R+AQ>b?E}oj8v9o4*Nnj&-EQOq3rYq_yr*;IV3+}J!>+#WvSz_H{Oru@>kup=I~h> z=Rh)Eq!CwN0^8k%Id7AduudnAp<$8y+j%SgbuVtuM>`jf?*VbrHK9sn7YMxh=Hmm@ zbgK)fdG4?gM(TQoN@-M&M&ku^!hvRz5>=uiG7OiRCO05EW9xJ>Y@k+sFt^*E(1pmy zwq-B;_R6ENfjHsR8qDm;EQU;4!Azsw(p#*24THXs%(_(2en{jE2 zLsDqCCOUdsJJ4C~Y`c7D#K%su4^;sK!N|q^{c8p@sBU#cJTChZ-8x=38OU|LEiF94{G-Ilj?H-mt5b?zy5g8KFIlrzDJ{8UrQpC5bE@|xR&^>s z@Qq(ycBr{LJe1B|Y6tN^Hwh!3?T_o;`OGCvMT_TDwaow<&Jxem!n(rS*OQ~L*fSTH z78$}rON((x#@$GA>HRxjq*(!@`i&r+81uKMk=H|{?2|f$<-UNTNd`0a7;RQC+Ml&U zadC2ao|N8~SSrvZd|cNSb~5<_RT=ZqpO4K>Yb5Vb@xp`spyZ)Ro8L5MSRpgF*_)2W|K z=jSv&-bD!4Z+D0F*=+73jrEn~LsjWihH@f`zC@_GO7fJLp`E+C{9tZIg(#`3CJ5(C zKkEsd#>nyUPq~9GnSpb z>;8hl+Ms`!?rnC-(x#BHs+q~u(y>)%?5xRWOcs|eOBzIM^*>YurB!gH@lM;i)O2Mi<6Zj>9yY-RuQiW0eq$yt0^_&g!!lz*~ zcDkztCTX;(&5Qk*&~U!QFrnQ~oXLJx=4f#127P_gicW8;7^}ME41U zW1HS@kKUFg7y7nTX{RRwUzqW^wX(N9gI%W=rF3+#+hn7q!j_@F<^zW&j*rt8b2Ce` z-8zY`Uk9QD;}9;7nNF-}1hP|laObH{k4>m_ z2;(5owx*{h9??m^4oTB-fLz~RpVlI|O+y-1(ZTciJyady#x_`Nu{!evc6J_OIdzqA z{LF(3wHdcP(HY?g1_+GJ)ODb*39gycFo>91 z)=}3Gx9t7)=y!V*1Y}`LRC5{qq|8i)s?l>itOx^!=S?7X z=kDe6)yG=pR@ur;BUG@*kG}hUZ_R;5(Ky+A-0fjJJhH=hykg7)KCfmmbyyQpr-@4q zO}9PtLL{!t8rxk|6x2+7em*2DsGyKc#({yfM7e91i<+4z!3+?=3_NB)0HCsJcageA z2r|uBDodDqtzFPIbRj&YS99T~rSu)7N4&u418~6bg$$#9KkdH=WU8IQuL-JZY+iG& zOUqf*Eldp!J?;eW(QsyndsgY&ORR-(hv?7)UkQ^>_Hgl{K+ljher6d!)mTXcg|{mE z&2};8g9kI0t#iD4W!450@z9l>YN~i??%;;DL9gXJQqmkY##K7-=Opb}8%H*zY!#Lh zq}SPjE$k}3$huI2MVAe2nf0eDIE2r7tGKk#v~{u%4rZL)q|hl{x+Q^`U&|BBd6b3^RUQE<&yH|H1ex#s53<8+I zAeuKlSjL9pcC5R!%T|eWBW76RGQ#XQC!=WKvwZ2L>nC&o2q0{4Y{1${q37G9{|90M zm;iLv7N6Llq9q%Sm5zonW!WnG^4kLg$|i;EBE3kqK@&@;dpKVmEcYan=Q0Ox{0QUQ zqR}ehZq+X}-5`a?>6&%`2#@@$mLZMH1lL}=DDJP3LmP{GF|8$pD2K|ais-1*0F6D} zO%woTPSMOTEg`TJ0Tk^@Zl$U>PU28K@@;-!`k)R}JoFKaITh)_1Rs;y=1^{QtY5F` zoW%_6-RbTnwr~T)MMZ2w7+ip{Z2!6h_g_pI!kW*!OB#4bG!b-f(oG*4QkTj^kiE!U z5ao0b7k*^*GEphJ93F88AZ?Y#a);TAYRzT4fh%BLs7k0E?BVrl&-XO=7lBz7@7>;H z{h8CP@K#$(0>xHWEip2_Ggbk{1BqX>edyrYv2(_zUw5ytD#`)gx~8JKKcTHd4nlp< z7dEWTs4VGdo5RtmzyHNDnc3FAYmo&vSOC z>FDNNl-2v;x7N`vA`7Gb55SY${+UYj@usJ;%Q(&UmRCcv)zbCcWF!EhB66V z;yX4RGE{Y$VQqNp%17!ZJ4#lA`TPjdjkn)cF^?W9SIy{o-J|}ZEm5x#-2ilQ+c_Un zg2uKr*(?-9pr;a^pk+YhS#oY*S*KVFD@|c%;PQ}#7%etY&AGDw!rNzz_|&3ELr^TG zwV94C^u!5xLED&I2u}9)Y0S|Y?h{9wczhtyOeutgZ9eQ{S&gBlsz=+#;BuS$r-0)6 z&C}M^8+)=$$lsY*_Rx@#uCY+m1Dt7T1pveV_c2{f+CXI>G0jMs)_EByn29&mc3PGO zKvB`#>@G%kaNrLMm=Tx&Fo+2_E5fRSmH$_BJ&N8+0Uw+(Xd14?j1BTaSG3S)ubd|f zAOPS^T`Nx@J>d4(>4PJBl77hu1OS~TdwHHPha$KYlQ|Y8mBXXnzPN61DGTf4xX8yw zn!B_P#>Q-%-)A*$C3^c$(Zmh_qxo=lmrJ6dg=-$36J}B<3R+Vrt~U+_rbuK4E=0PH zBd%SP$MM)6pE-~v1v42T6DnM8G!Tn=fQBf81eCO|G6ss-uZ_%P3qD=QLlAgNqHHMcA>< zSXW^%E>g~J-0;V^AZ|n4gEs#@)u*x|48pOL$04M@?mWR5XD*V+reT3L07;pFJpcSg z&=im8VP}V!sMLz-wxjIIi0+2m`DIfWsm)={$aPh1I6%4~(#sg3^x3{wuqV*_M5KA@ zKmT|Spkyr`(q|0ug}dpYrA1u;X}5cEY=2O^WXZG{ZD?#NT})NmhjQ5^#7Yl{5wa~{ z6jdY6C�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
  • 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 literal 0 HcmV?d00001 diff --git a/features/tangempay/onboarding/impl/src/main/res/drawable/img_tangem_pay_onboarding.webp b/features/tangempay/onboarding/impl/src/main/res/drawable/img_tangem_pay_onboarding.webp new file mode 100644 index 0000000000000000000000000000000000000000..224069447490cb98d77f06a4b903ce29d5c84c1a GIT binary patch literal 196856 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ⅈ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

    B9#2Y>QqceT+Qww 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%0D+*=5uqX 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�^mof!NtFBY}U3}+Z=1yQByf#4LE$NLDrGEP-AY$ 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 literal 0 HcmV?d00001 From bc5db9ba12d3b9e74d6b16dcd7d96cfe854e35e0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 10:12:21 +0300 Subject: [PATCH 074/132] Updated on 2026-08-14 --- .../data/onramp/DefaultOnrampRepository.kt | 8 ++++---- .../main/model/OnrampMainComponentModel.kt | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index 1bbb60d6ea..6d8b5311ce 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -295,9 +295,10 @@ internal class DefaultOnrampRepository( }, ) - val mercuryoProvider = onrampPairs.map { it.providers }.flatten() - .find { it.providerId == MERCURYO_PROVIDER_ID } - val hasSepaMethod = mercuryoProvider?.paymentMethods?.any { it == SEPA_METHOD_ID } ?: false + val hasSepaMethod = onrampPairs + .flatMap { it.providers } + .flatMap { it.paymentMethods } + .any { it == SEPA_METHOD_ID } hasSepaMethod } @@ -598,6 +599,5 @@ internal class DefaultOnrampRepository( const val REDIRECT_URL = "https://tangem.com/onramp" const val SEPA_METHOD_ID = "sepa" - const val MERCURYO_PROVIDER_ID = "mercuryo" } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 71399a3ce4..99c271c260 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -149,19 +149,21 @@ internal class OnrampMainComponentModel @Inject constructor( ifLeft = ::handleOnrampError, ifRight = { country -> if (country == null) return@onEach + + val wasInitialLoading = _state.value is OnrampMainComponentUM.InitialLoading _state.update { if (it is OnrampMainComponentUM.InitialLoading) { - val state = stateFactory.getReadyState(country.defaultCurrency) - if (params.launchSepa) { - onAmountValueChanged(PREDEFINED_SEPA_AMOUNT) - } - state + stateFactory.getReadyState(country.defaultCurrency) } else { amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } } updatePairsAndQuotes() + + if (wasInitialLoading && params.launchSepa) { + onAmountValueChanged(PREDEFINED_SEPA_AMOUNT) + } }, ) } @@ -326,9 +328,9 @@ internal class OnrampMainComponentModel @Inject constructor( val sepaQuote = if (params.launchSepa && !isSepaLaunched) { isSepaLaunched = true - quotes.filterIsInstance().firstOrNull { - it.provider.id == MERCURYO_PROVIDER_ID && it.paymentMethod.id == SEPA_METHOD_ID - } + quotes.filterIsInstance() + .filter { it.paymentMethod.id == SEPA_METHOD_ID } + .maxByOrNull { it.toAmount.value } } else { null } @@ -437,7 +439,6 @@ internal class OnrampMainComponentModel @Inject constructor( private companion object { const val UPDATE_DELAY = 10_000L - const val MERCURYO_PROVIDER_ID = "mercuryo" const val SEPA_METHOD_ID = "sepa" } } \ No newline at end of file From 1755406d31b182412aa00122a1a653155c27db73 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 12:32:56 +0300 Subject: [PATCH 075/132] Updated on 2026-08-14 --- .../com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index dde88e9295..ea548398d0 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -12,6 +12,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler +import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -73,6 +74,10 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } + private val onboardVisaDeepLink = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val cardSdkProvider = mockk(relaxed = true) { every { sdk.uiVisibility() } returns MutableStateFlow(false) } @@ -97,6 +102,7 @@ class DeepLinkFactoryTest { sellDeepLink = sellDeepLinkFactory, swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, + onboardVisaDeepLink = onboardVisaDeepLink, ) @OptIn(ExperimentalCoroutinesApi::class) From 410253dc5acdfc0efe5bb6e81a7744e3e9ada257 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 14:46:14 +0500 Subject: [PATCH 076/132] Updated on 2026-08-14 --- .../com/tangem/scenarios/BaseScenarios.kt | 6 +- .../tangem/screens/MainScreenPageObject.kt | 40 +++-- .../tests/balance/TotalBalanceLongTapTest.kt | 43 ++++++ .../balance/TotalBalanceUnavailableTest.kt | 145 ++++++++++++++++++ .../tangem/core/ui/test/MainScreenTestTags.kt | 5 +- .../wallet/ui/components/common/WalletCard.kt | 11 +- 6 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 272707ea7c..69e061f616 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -4,10 +4,6 @@ import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType import com.tangem.screens.* -import com.tangem.screens.onDisclaimerScreen -import com.tangem.screens.onMainScreen -import com.tangem.screens.onMarketsTooltipScreen -import com.tangem.screens.onStoriesScreen import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockProvider import io.qameta.allure.kotlin.Allure.step @@ -47,7 +43,7 @@ fun BaseTestCase.synchronizeAddresses(balance: String) { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } step("Assert wallet balance = '$balance'") { - onMainScreen { walletBalance().assertTextContains(balance) } + onMainScreen { totalBalanceText.assertTextContains(balance) } } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 43ad397162..7c145b6ff8 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -3,11 +3,12 @@ package com.tangem.screens import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasAnyAncestor import com.tangem.common.BaseTestCase import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.utils.LazyListItemNode -import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.feature.wallet.impl.R @@ -72,6 +73,24 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + val totalBalanceContainer: KNode = child { + hasTestTag(MainScreenTestTags.WALLET_LIST_ITEM) + } + + val totalBalanceMenuRenameWallet: KNode = child { + hasTestTag(MainScreenTestTags.TOTAL_BALANCE_MENU_ITEM) + hasText(getResourceString(R.string.common_rename)) + } + + val totalBalanceMenuDeleteWallet: KNode = child { + hasTestTag(MainScreenTestTags.TOTAL_BALANCE_MENU_ITEM) + hasText(getResourceString(R.string.common_delete)) + } + + val totalBalanceText: KNode = child { + hasParent(withTestTag(MainScreenTestTags.WALLET_BALANCE)) + } + /** * Find token list item with title and address */ @@ -86,19 +105,6 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } - /** - * Find node with wallet balance using lazyList. This construction doesn't affect next step with lazyList. - */ - @OptIn(ExperimentalTestApi::class) - fun walletBalance(): KNode { - return lazyList.childWith { - hasAnyDescendant(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM)) - }.child { - hasTestTag(MainScreenTestTags.WALLET_BALANCE) - useUnmergedTree = true - } - } - @OptIn(ExperimentalTestApi::class) fun organizeTokensButton(): KNode { return lazyList.childWith { @@ -129,6 +135,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + fun KNode.assertIsUnreachable() { + this { + hasAnyAncestor(withText(getResourceString(R.string.common_unreachable))) + assertIsDisplayed() + } + } /** * This assertion is required to properly verify the token's absence in the semantic tree. diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt new file mode 100644 index 0000000000..05e052bdd4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt @@ -0,0 +1,43 @@ +package com.tangem.tests.balance + +import androidx.compose.ui.test.longClick +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TotalBalanceLongTapTest : BaseTestCase() { + + @Test + @AllureId("3965") + @DisplayName("Total balance: check long tap on block without biometry") + fun whenBiometryIsOffTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Long tap on total balance block") { + onMainScreen { + totalBalanceContainer.performTouchInput { + longClick() + } + } + } + step("Assert 'Rename' button is not displayed") { + onMainScreen { totalBalanceMenuRenameWallet.assertIsNotDisplayed() } + } + step("Assert 'Delete' button is not displayed") { + onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt new file mode 100644 index 0000000000..f44bf4ffa4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt @@ -0,0 +1,145 @@ +package com.tangem.tests.balance + +import com.tangem.common.BaseTestCase +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import com.tangem.utils.StringsSigns.DASH_SIGN +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TotalBalanceUnavailableTest : BaseTestCase() { + + @Test + @AllureId("148") + @DisplayName("Total balance: check dash sign when addresses are not derived") + fun whenDerivationsDoesNotSyncedTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Do not synchronize addresses") { + onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + } + } + + @Test + @AllureId("3993") + @DisplayName("Total balance: check dash sign when network is unreachable") + fun whenNetworkIsUnreachableTest() { + val scenarioName = "eth_network_balance" + val scenarioState = "Unreachable" + val tokenTitle = "Ethereum" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(DASH_SIGN) + } + step("Assert 'Synchronize addresses' button does not exist") { + onMainScreen { + flakySafely { + synchronizeAddressesButton.assertDoesNotExist() + } + } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + step("Assert $tokenTitle is unreachable") { + onMainScreen { tokenWithTitleAndPosition(tokenTitle, 1).assertIsUnreachable() } + } + } + } + + @Test + @AllureId("3994") + @DisplayName("Total balance: check dash sign when quotes are failed") + fun whenQuotesAreFailedTest() { + val scenarioName = "quotes_api" + val scenarioState = "Error" + val tokenTitle = "Ethereum" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(DASH_SIGN) + } + step("Assert 'Synchronize addresses' button does not exist") { + onMainScreen { + flakySafely { + synchronizeAddressesButton.assertDoesNotExist() + } + } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + step("Assert $tokenTitle is unreachable") { + onMainScreen { tokenWithTitleAndPosition(tokenTitle, 1).assertIsUnreachable() } + } + } + } + + @Test + @AllureId("3995") + @DisplayName("Total balance: check dash sign when added custom token without rate") + fun whenCustomTokenWithoutRateAddedTest() { + val scenarioName = "user_tokens_api" + val scenarioState = "CustomTokenAdded" + val tokenTitle = "Myria" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(DASH_SIGN) + } + step("Assert 'Synchronize addresses' button does not exist") { + onMainScreen { + flakySafely { + synchronizeAddressesButton.assertDoesNotExist() + } + } + } + step("Assert dash sign is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + step("Assert $tokenTitle is unreachable") { + onMainScreen { tokenWithTitleAndPosition(tokenTitle, 4).assertIsUnreachable() } + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt index dfef1326a7..117cfefc69 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt @@ -5,8 +5,11 @@ object MainScreenTestTags { const val MORE_BUTTON = "MAIN_SCREEN_MORE_BUTTON" const val TOP_BAR = "MAIN_SCREEN_TOP_BAR" const val TOKEN_LIST_ITEM = "MAIN_SCREEN_TOKEN_LIST_ITEM" - const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE" const val WALLET_LIST_ITEM = "MAIN_SCREEN_WALLET_LIST_ITEM" const val ORGANIZE_TOKENS_BUTTON = "MAIN_SCREEN_ORGANIZE_TOKENS_BUTTON" const val MULTI_CURRENCY_ACTION_BUTTON = "MAIN_SCREEN_MULTI_CURRENCY_ACTION_BUTTON" + + const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE" + const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM" + const val TOTAL_BALANCE_CONTAINER = "MAIN_SCREEN_TOTAL_BALANCE_CONTAINER" } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index 9b0b6f2e1e..d953f8a8ba 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -164,6 +164,7 @@ private fun CardContainer( modifier = modifier .defaultMinSize(minHeight = TangemTheme.dimens.size108) .onSizeChanged { itemSize = it } + .testTag(MainScreenTestTags.TOTAL_BALANCE_CONTAINER) .then( if (isLockedState || dropDownItems.isEmpty()) { Modifier @@ -244,7 +245,9 @@ private fun ManageWalletContextMenu( private fun MenuItem(text: TextReference, imageVector: ImageVector, onClick: () -> Unit) { DropdownMenuItem( text = { Text(text = text.resolveReference(), style = TangemTheme.typography.subtitle2) }, - modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + modifier = Modifier + .background(color = TangemTheme.colors.background.secondary) + .testTag(MainScreenTestTags.TOTAL_BALANCE_MENU_ITEM), trailingIcon = { Icon(imageVector = imageVector, contentDescription = null) }, onClick = onClick, colors = MenuDefaults.itemColors( @@ -271,7 +274,7 @@ private fun Balance(state: WalletCardState, isBalanceHidden: Boolean, modifier: AnimatedContent( targetState = (state as? WalletCardState.Content)?.balance?.orMaskWithStars(isBalanceHidden).orEmpty(), label = "Update the balance", - modifier = modifier, + modifier = modifier.testTag(MainScreenTestTags.WALLET_BALANCE), transitionSpec = { fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith fadeOut(animationSpec = tween(durationMillis = 90)) @@ -280,9 +283,7 @@ private fun Balance(state: WalletCardState, isBalanceHidden: Boolean, modifier: when (state) { is WalletCardState.Content -> { ResizableText( - modifier = Modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size32) - .testTag(MainScreenTestTags.WALLET_BALANCE), + modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32), text = balance, fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize), overflow = TextOverflow.Ellipsis, From 3c3f35aeb60e30fcee8370ee3bd3f2b504974b99 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 15:53:28 +0500 Subject: [PATCH 077/132] Updated on 2026-08-14 --- .../domain/scanCard/LegacyScanProcessor.kt | 2 +- .../tangem/tap/routing/utils/ChildFactory.kt | 17 +- .../com/tangem/common/routing/AppRoute.kt | 16 +- .../wallets/hot/DefaultHotWalletAccessor.kt | 13 +- .../domain/wallets/hot/HotWalletAccessor.kt | 3 + .../ClearHotWalletContextualUnlockUseCase.kt | 7 + features/hot-wallet/impl/build.gradle.kts | 2 + .../hotwallet/accesscode/AccessCodeModel.kt | 5 +- .../upgradewallet/UpgradeWalletModel.kt | 111 ++++++++++- .../upgradewallet/entity/UpgradeWalletUM.kt | 1 + .../viewphrase/model/ViewPhraseModel.kt | 6 +- .../v2/entry/OnboardingEntryComponent.kt | 14 +- features/onboarding-v2/impl/build.gradle.kts | 1 + .../entry/impl/model/OnboardingEntryModel.kt | 12 +- .../api/OnboardingMultiWalletComponent.kt | 10 +- .../DefaultOnboardingMultiWalletComponent.kt | 7 + .../model/MultiWalletFinalizeModel.kt | 10 + .../MultiWalletUpgradeWalletComponent.kt | 56 ++++++ .../model/MultiWalletUpgradeWalletModel.kt | 181 ++++++++++++++++++ .../ui/MultiWalletUpgradeWallet.kt | 98 ++++++++++ .../ui/state/MultiWalletUpgradeWalletUM.kt | 11 ++ .../v2/multiwallet/impl/di/ComponentModule.kt | 6 + .../impl/model/OnboardingMultiWalletModel.kt | 3 + .../impl/model/OnboardingMultiWalletState.kt | 9 +- .../v2/multiwallet/impl/model/Utils.kt | 1 + .../model/WalletSettingsModel.kt | 50 +++-- 26 files changed, 589 insertions(+), 63 deletions(-) create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/MultiWalletUpgradeWallet.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/state/MultiWalletUpgradeWalletUM.kt diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index ea780d4fa5..5b44a91292 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -193,7 +193,7 @@ internal class LegacyScanProcessor @Inject constructor( } } - @Suppress("LongMethod", "MagicNumber") + @Suppress("LongMethod", "LongParameterList", "MagicNumber") private suspend inline fun onScanSuccess( scanResponse: ScanResponse, crossinline onProgressStateChange: suspend (showProgress: Boolean) -> Unit, 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 29a693971b..6035f506dc 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 @@ -237,14 +237,19 @@ internal class ChildFactory @Inject constructor( context = context, params = OnboardingEntryComponent.Params( scanResponse = route.scanResponse, - mode = when (route.mode) { - AppRoute.Onboarding.Mode.Onboarding -> OnboardingEntryComponent.Mode.Onboarding - AppRoute.Onboarding.Mode.AddBackupWallet1 -> OnboardingEntryComponent.Mode.AddBackupWallet1 - AppRoute.Onboarding.Mode.WelcomeOnlyTwin -> OnboardingEntryComponent.Mode.WelcomeOnlyTwin - AppRoute.Onboarding.Mode.RecreateWalletTwin -> + mode = when (val mode = route.mode) { + is AppRoute.Onboarding.Mode.Onboarding -> + OnboardingEntryComponent.Mode.Onboarding + is AppRoute.Onboarding.Mode.AddBackupWallet1 -> + OnboardingEntryComponent.Mode.AddBackupWallet1 + is AppRoute.Onboarding.Mode.WelcomeOnlyTwin -> + OnboardingEntryComponent.Mode.WelcomeOnlyTwin + is AppRoute.Onboarding.Mode.RecreateWalletTwin -> OnboardingEntryComponent.Mode.RecreateWalletTwin - AppRoute.Onboarding.Mode.ContinueFinalize -> + is AppRoute.Onboarding.Mode.ContinueFinalize -> OnboardingEntryComponent.Mode.ContinueFinalize + is AppRoute.Onboarding.Mode.UpgradeHotWallet -> + OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId) }, ), componentFactory = onboardingEntryComponentFactory, 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 96fcdbc5fd..3ca2c4c565 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 @@ -274,14 +274,16 @@ sealed class AppRoute(val path: String) : Route { data class Onboarding( val scanResponse: ScanResponse, val mode: Mode = Mode.Onboarding, - ) : AppRoute(path = "/onboarding_v2/${mode.name}") { + ) : AppRoute(path = "/onboarding_v2/$mode") { - enum class Mode { - Onboarding, // general Mode - AddBackupWallet1, // continue backup process for existing wallet 1 - WelcomeOnlyTwin, // show welcome screen and then navigate to wallet for twins - RecreateWalletTwin, // reset twins - ContinueFinalize, // continue finalize process (unfinished backup dialog) + @Serializable + sealed class Mode { + data object Onboarding : Mode() // general Mode + data object AddBackupWallet1 : Mode() // continue backup process for existing wallet 1 + data object WelcomeOnlyTwin : Mode() // show welcome screen and then navigate to wallet for twins + 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 } } diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index 33dc7635a7..fdf4a50d73 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -3,6 +3,7 @@ package com.tangem.data.wallets.hot import com.tangem.common.core.TangemSdkError import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.repository.WalletsRepository @@ -56,12 +57,22 @@ class DefaultHotWalletAccessor @Inject constructor( contextualUnlockHotWallet[hotWalletId] override fun clearContextualUnlock(hotWalletId: HotWalletId) { - contextualUnlockHotWallet[hotWalletId] = null + contextualUnlockHotWallet.remove(hotWalletId) scope.launch { tangemHotSdk.clearUnlockContext(hotWalletId) } } + override fun clearContextualUnlock(userWalletId: UserWalletId) { + scope.launch { + val userWallet = userWalletsListRepository.userWalletsSync() + .find { it is UserWallet.Hot && it.walletId == userWalletId } + as? UserWallet.Hot + ?: return@launch + clearContextualUnlock(userWallet.hotWalletId) + } + } + override fun clearAllContextualUnlock() { val hotWalletsIds = contextualUnlockHotWallet.keys.toList() contextualUnlockHotWallet.clear() diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt index 41fc7f5fcb..ca71e8b7a6 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt @@ -1,5 +1,6 @@ package com.tangem.domain.wallets.hot +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.hot.sdk.model.* interface HotWalletAccessor { @@ -16,5 +17,7 @@ interface HotWalletAccessor { fun clearContextualUnlock(hotWalletId: HotWalletId) + fun clearContextualUnlock(userWalletId: UserWalletId) + fun clearAllContextualUnlock() } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt index be1a030837..caf1ea5457 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ClearHotWalletContextualUnlockUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.model.HotWalletId @@ -14,4 +15,10 @@ class ClearHotWalletContextualUnlockUseCase( hotWalletAccessor.clearContextualUnlock(hotWalletId) } } + + operator fun invoke(userWalletId: UserWalletId): Either { + return Either.catch { + hotWalletAccessor.clearContextualUnlock(userWalletId) + } + } } \ No newline at end of file diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 2f25fc3478..481078432a 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -34,6 +34,8 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.models) implementation(projects.domain.settings) + implementation(projects.domain.feedback) + implementation(projects.domain.feedback.models) /** Common */ implementation(projects.common.ui) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt index e611297547..404b5091bb 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt @@ -23,7 +23,6 @@ import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM import com.tangem.features.hotwallet.impl.R import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth -import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.UnlockHotWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow @@ -54,8 +53,6 @@ internal class AccessCodeModel @Inject constructor( private val params = paramsContainer.require() - private var hotWalletId: HotWalletId? = null - internal val uiState: StateFlow field = MutableStateFlow(getInitialState()) @@ -197,7 +194,7 @@ internal class AccessCodeModel @Inject constructor( } override fun onDestroy() { - hotWalletId?.let { clearHotWalletContextualUnlockUseCase.invoke(it) } + clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) super.onDestroy() } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt index bc03e54e43..00ca37071d 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt @@ -1,33 +1,71 @@ package com.tangem.features.hotwallet.upgradewallet +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.doOnFailure +import com.tangem.common.doOnSuccess +import com.tangem.common.routing.AppRoute 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.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.toWrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.features.hotwallet.UpgradeWalletComponent import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.sdk.extensions.localizedDescriptionRes import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class UpgradeWalletModel @Inject constructor( + paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val uiMessageSender: UiMessageSender, + private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, + private val tangemSdkManager: TangemSdkManager, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, ) : Model() { + private val params = paramsContainer.require() - internal val uiState: StateFlow - field = MutableStateFlow( + private val _uiState = MutableStateFlow( UpgradeWalletUM( onBackClick = { router.pop() }, onBuyTangemWalletClick = ::onBuyTangemWalletClick, onScanDeviceClick = ::onScanDeviceClick, ), ) + internal val uiState: StateFlow = _uiState + + override fun onDestroy() { + clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) + super.onDestroy() + } private fun onBuyTangemWalletClick() { modelScope.launch { @@ -36,6 +74,73 @@ internal class UpgradeWalletModel @Inject constructor( } private fun onScanDeviceClick() { - // TODO [REDACTED_TASK_KEY] + scanCard() + } + + private fun scanCard() { + modelScope.launch { + setLoading(true) + + val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() + cardSdkConfigRepository.setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = shouldSaveAccessCodes, + ) + + tangemSdkManager + .scanProduct() + .doOnSuccess { + delay(DELAY_SDK_DIALOG_CLOSE) + tangemSdkManager.changeDisplayedCardIdNumbersCount(it) + navigateToUpgradeFlow(it) + } + .doOnFailure { + showCardVerificationFailedDialog(it) + } + } + } + + private fun setLoading(isLoading: Boolean) { + _uiState.update { it.copy(isLoading = isLoading) } + } + + private fun showCardVerificationFailedDialog(error: TangemError) { + if (error !is TangemSdkError.CardVerificationFailed) return + + // TODO [REDACTED_TASK_KEY] track error + + val resource = error.localizedDescriptionRes() + val resId = resource.resId ?: R.string.common_unknown_error + val resArgs = resource.args.map { it.value } + + uiMessageSender.send( + DialogMessage( + message = resourceReference(id = resId, resArgs.toWrappedList()), + title = resourceReference(id = R.string.security_alert_title), + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(id = R.string.alert_button_request_support), + onClick = { + modelScope.launch { + sendFeedbackEmailUseCase(type = FeedbackEmailType.CardAttestationFailed) + } + }, + ) + }, + secondActionBuilder = { cancelAction(onClick = {}) }, + ), + ) + } + + private fun navigateToUpgradeFlow(scanResponse: ScanResponse) { + setLoading(false) + router.push( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.UpgradeHotWallet( + userWalletId = params.userWalletId, + ), + ), + ) } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt index 8f700a27b9..ff2ae172d9 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt @@ -4,4 +4,5 @@ internal data class UpgradeWalletUM( val onBackClick: () -> Unit, val onBuyTangemWalletClick: () -> Unit, val onScanDeviceClick: () -> Unit, + val isLoading: Boolean = false, ) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt index f2d3adf53e..ede4241680 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/model/ViewPhraseModel.kt @@ -13,7 +13,6 @@ import com.tangem.domain.wallets.usecase.ExportSeedPhraseUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.hotwallet.ViewPhraseComponent import com.tangem.features.hotwallet.viewphrase.entity.ViewPhraseUM -import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -35,8 +34,6 @@ internal class ViewPhraseModel @Inject constructor( private val params = paramsContainer.require() - private var hotWalletId: HotWalletId? = null - internal val uiState: StateFlow field = MutableStateFlow( ViewPhraseUM( @@ -52,7 +49,6 @@ internal class ViewPhraseModel @Inject constructor( val userWallet = getUserWalletUseCase(params.userWalletId) .getOrElse { error("User wallet with id ${params.userWalletId} not found") } if (userWallet is UserWallet.Hot) { - hotWalletId = userWallet.hotWalletId modelScope.launch { val words = exportSeedPhraseUseCase.invoke(userWallet.hotWalletId) .getOrElse { error("Unable to export seed phrase for wallet with id ${params.userWalletId}") } @@ -70,7 +66,7 @@ internal class ViewPhraseModel @Inject constructor( } override fun onDestroy() { - hotWalletId?.let { clearHotWalletContextualUnlockUseCase.invoke(it) } + clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) super.onDestroy() } } \ No newline at end of file diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt index b52fabf421..a21785815f 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.entry import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWalletId interface OnboardingEntryComponent : ComposableContentComponent { @@ -11,12 +12,13 @@ interface OnboardingEntryComponent : ComposableContentComponent { val mode: Mode, ) - enum class Mode { - Onboarding, - AddBackupWallet1, - WelcomeOnlyTwin, - RecreateWalletTwin, - ContinueFinalize, + sealed class Mode { + data object Onboarding : Mode() + data object AddBackupWallet1 : Mode() + data object WelcomeOnlyTwin : Mode() + data object RecreateWalletTwin : Mode() + data object ContinueFinalize : Mode() + data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index d6abeb7356..7febfb81ba 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(projects.domain.transaction) /** Tangem libraries */ + implementation(tangemDeps.hot.core) implementation(projects.libs.tangemSdkApi) implementation(tangemDeps.card.core) implementation(tangemDeps.card.android) { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index 0ac07b8a3e..ff0ace4c4a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -73,16 +73,20 @@ internal class OnboardingEntryModel @Inject constructor( uiMessageSender.send(CantLeaveBackupDialog) } + @Suppress("CyclomaticComplexMethod") private fun routeByProductType(scanResponse: ScanResponse): OnboardingRoute { return when (scanResponse.productType) { ProductType.Wallet, ProductType.Wallet2, ProductType.Ring, -> { - val multiWalletNavigationMode = when (params.mode) { - Mode.Onboarding -> OnboardingMultiWalletComponent.Mode.Onboarding - Mode.AddBackupWallet1 -> OnboardingMultiWalletComponent.Mode.AddBackup - Mode.ContinueFinalize -> OnboardingMultiWalletComponent.Mode.ContinueFinalize + val multiWalletNavigationMode = when (val mode = params.mode) { + is Mode.Onboarding -> OnboardingMultiWalletComponent.Mode.Onboarding + is Mode.AddBackupWallet1 -> OnboardingMultiWalletComponent.Mode.AddBackup + is Mode.ContinueFinalize -> OnboardingMultiWalletComponent.Mode.ContinueFinalize + is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( + userWalletId = mode.userWalletId, + ) else -> error("Incorrect onboarding type") } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt index a6c7896af2..75a31024e6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigationHolder import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.onboarding.v2.TitleProvider interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavigationHolder { @@ -17,10 +18,11 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi val onDone: (UserWallet.Cold) -> Unit, ) - enum class Mode { - Onboarding, - AddBackup, - ContinueFinalize, + sealed class Mode { + data object Onboarding : Mode() + data object AddBackup : Mode() + data object ContinueFinalize : Mode() + data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 90696c234b..6a1d3cf78e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -35,6 +35,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.Mul import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.MultiWalletFinalizeComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.scanprimary.MultiWalletScanPrimaryComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.MultiWalletSeedPhraseComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.MultiWalletUpgradeWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.Step.* @@ -57,6 +58,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor private val artworksState = instanceKeeper.getOrCreateSimple(key = "artworksState") { MutableStateFlow( when (model.state.value.currentStep) { + UpgradeWallet -> WalletArtworksState.Folded CreateWallet -> WalletArtworksState.Folded ChooseBackupOption -> WalletArtworksState.Fan SeedPhrase -> WalletArtworksState.Folded @@ -142,6 +144,11 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor childContext: AppComponentContext, ): ComposableContentComponent { return when (step) { + UpgradeWallet -> MultiWalletUpgradeWalletComponent( + context = childContext, + params = childParams, + onNextStep = ::handleNavigationEvent, + ) CreateWallet -> MultiWalletCreateWalletComponent( context = childContext, params = childParams, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index e05be61546..65dd32233d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -265,6 +265,16 @@ internal class MultiWalletFinalizeModel @Inject constructor( userWallet } + is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { + saveWalletUseCase( + userWallet = userWalletCreated.copy( + scanResponse = scanResponse.updateScanResponseAfterBackup(), + ), + canOverride = true, + ) + // TODO [REDACTED_TASK_KEY] remove hot wallet after upgrade + userWalletCreated + } }.requireColdWallet() if (hasRing) { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt new file mode 100644 index 0000000000..222b173ffb --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt @@ -0,0 +1,56 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.essenty.lifecycle.doOnStart +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model.MultiWalletUpgradeWalletModel +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.MultiWalletUpgradeWallet +import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class MultiWalletUpgradeWalletComponent( + context: AppComponentContext, + params: MultiWalletChildParams, + onNextStep: (OnboardingMultiWalletState.Step) -> Unit, +) : AppComponentContext by context, MultiWalletChildComponent { + + private val model: MultiWalletUpgradeWalletModel = getOrCreateModel(params) + + init { + lifecycle.doOnStart { + params.innerNavigation.update { + it.copy( + stackSize = 2, + stackMaxSize = 9, + ) + } + + params.parentParams.titleProvider.changeTitle( + text = resourceReference(R.string.common_tangem), + ) + } + + componentScope.launch { + model.onDone.collect(onNextStep) + } + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + MultiWalletUpgradeWallet( + modifier = modifier, + state = state, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt new file mode 100644 index 0000000000..f6b6306cd6 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/model/MultiWalletUpgradeWalletModel.kt @@ -0,0 +1,181 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.CompletionResult +import com.tangem.common.core.TangemSdkError +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.ui.extensions.resourceReference +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.ExportSeedPhraseUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.state.MultiWalletUpgradeWalletUM +import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog +import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.Step +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class MultiWalletUpgradeWalletModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val tangemSdkManager: TangemSdkManager, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, + private val cardRepository: CardRepository, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + private val saveWalletUseCase: SaveWalletUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val exportSeedPhraseUseCase: ExportSeedPhraseUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val multiWalletState + get() = params.multiWalletState + + private val _uiState = MutableStateFlow( + MultiWalletUpgradeWalletUM( + title = resourceReference(R.string.hw_upgrade_start_title), + bodyText = resourceReference(R.string.hw_upgrade_start_description), + onStartUpgradeClick = { + // TODO [REDACTED_TASK_KEY] track button click + upgradeWallet(false) + }, + dialog = null, + ), + ) + + val uiState: StateFlow = _uiState + val onDone = MutableSharedFlow() + + init { + // TODO [REDACTED_TASK_KEY] track screen opened + } + + private fun upgradeWallet(shouldReset: Boolean) { + modelScope.launch { + val mode = params.parentParams.mode + require(mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet) + + val userWallet = getUserWalletUseCase(mode.userWalletId) + .getOrElse { error("User wallet with id ${mode.userWalletId} not found") } + if (userWallet is UserWallet.Hot) { + val privateInfo = exportSeedPhraseUseCase + .invoke(userWallet.hotWalletId) + .getOrElse { error("Unable to export seed phrase for wallet with id ${mode.userWalletId}") } + + modelScope.launch { + val result = tangemSdkManager.importWallet( + scanResponse = multiWalletState.value.currentScanResponse, + shouldReset = shouldReset, + mnemonic = privateInfo.mnemonic.mnemonicComponents.joinToString(" "), + passphrase = privateInfo.passphrase?.concatToString(), + ) + + when (result) { + is CompletionResult.Success -> { + multiWalletState.update { + it.copy( + currentScanResponse = it.currentScanResponse.copy( + card = result.data.card, + derivedKeys = result.data.derivedKeys, + primaryCard = result.data.primaryCard, + ), + ) + } + + cardRepository.startCardActivation(cardId = result.data.card.cardId) + + // TODO [REDACTED_TASK_KEY] track wallet created + val cardDoesNotSupportBackup = result.data.card.settings.isBackupAllowed.not() + when { + cardDoesNotSupportBackup -> createWalletAndNavigateBackWithDone() + params.parentParams.withSeedPhraseFlow -> onDone.emit(Step.AddBackupDevice) + else -> { + onDone.emit(Step.ChooseBackupOption) + } + } + } + + is CompletionResult.Failure -> { + if (result.error is TangemSdkError.WalletAlreadyCreated) { + // show should reset dialog + handleActivationError() + } + } + } + } + } + } + } + + private fun createWalletAndNavigateBackWithDone() { + modelScope.launch { + val scanResponse = params.multiWalletState.value.currentScanResponse + + val userWallet = createUserWallet(scanResponse) + saveWalletUseCase(userWallet, canOverride = true) + .onRight { + cardRepository.finishCardActivation(scanResponse.card.cardId) + + // save user wallet for manage tokens screen + params.multiWalletState.update { + it.copy(resultUserWallet = userWallet) + } + + onDone.emit(Step.Done) + } + } + } + + private fun createUserWallet(scanResponse: ScanResponse): UserWallet.Cold { + return requireNotNull( + value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build(), + lazyMessage = { "User wallet not created" }, + ) + } + + private fun handleActivationError() { + _uiState.update { state -> + state.copy( + dialog = resetCardDialog( + onConfirm = ::navigateToSupportScreen, + dismiss = { _uiState.update { it.copy(dialog = null) } }, + onDismissButtonClick = ::resetCard, + ), + ) + } + } + + private fun resetCard() { + upgradeWallet(true) + } + + fun navigateToSupportScreen() { + modelScope.launch { + val cardInfo = + getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch + sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) + } + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/MultiWalletUpgradeWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/MultiWalletUpgradeWallet.kt new file mode 100644 index 0000000000..28ffd8f2f8 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/MultiWalletUpgradeWallet.kt @@ -0,0 +1,98 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.state.MultiWalletUpgradeWalletUM + +@Composable +internal fun MultiWalletUpgradeWallet(state: MultiWalletUpgradeWalletUM, modifier: Modifier = Modifier) { + if (state.dialog != null) { + BasicDialog( + title = state.dialog.title.resolveReference(), + message = state.dialog.message.resolveReference(), + confirmButton = DialogButtonUM( + title = state.dialog.confirmButtonText.resolveReference(), + onClick = state.dialog.onConfirmClick, + ), + dismissButton = DialogButtonUM( + title = state.dialog.dismissButtonText.resolveReference(), + warning = state.dialog.dismissWarningColor, + onClick = state.dialog.onDismissButtonClick, + ), + onDismissDialog = state.dialog.onDismiss, + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + verticalArrangement = Arrangement.Bottom, + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 32.dp, end = 32.dp, bottom = 16.dp) + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp), + ) + + Text( + text = state.bodyText.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp), + ) + } + + PrimaryButtonIconEnd( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + iconResId = R.drawable.ic_tangem_24, + text = stringResourceSafe(R.string.hw_upgrade_start_action), + onClick = state.onStartUpgradeClick, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TangemThemePreview { + MultiWalletUpgradeWallet( + state = MultiWalletUpgradeWalletUM( + title = stringReference("Title"), + bodyText = stringReference("Body body body"), + onStartUpgradeClick = {}, + dialog = null, + ), + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/state/MultiWalletUpgradeWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/state/MultiWalletUpgradeWalletUM.kt new file mode 100644 index 0000000000..c61c12a810 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/ui/state/MultiWalletUpgradeWalletUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.state + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM + +internal data class MultiWalletUpgradeWalletUM( + val title: TextReference, + val bodyText: TextReference, + val onStartUpgradeClick: () -> Unit, + val dialog: OnboardingDialogUM?, +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt index 45a7844f49..86d6bdf5b2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/di/ComponentModule.kt @@ -11,6 +11,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.mod import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model.MultiWalletFinalizeModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.scanprimary.model.MultiWalletScanPrimaryModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.MultiWalletSeedPhraseModel +import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model.MultiWalletUpgradeWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletModel import dagger.Binds import dagger.Module @@ -72,4 +73,9 @@ internal interface ModelModule { @IntoMap @ClassKey(MultiWalletScanPrimaryModel::class) fun provideModel8(model: MultiWalletScanPrimaryModel): Model + + @Binds + @IntoMap + @ClassKey(MultiWalletUpgradeWalletModel::class) + fun provideModel9(model: MultiWalletUpgradeWalletModel): Model } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index bf9980e12d..9895662a0a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -117,6 +117,9 @@ internal class OnboardingMultiWalletModel @Inject constructor( val card = scanResponse.card return when { + params.mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { + OnboardingMultiWalletState.Step.UpgradeWallet + } params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> OnboardingMultiWalletState.Step.Finalize // Add backup button diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt index aeb5dc4011..d1fa192d94 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt @@ -32,7 +32,14 @@ data class OnboardingMultiWalletState( * -> AddBackupDevice -> Finalize -> [Done] */ enum class Step { - CreateWallet, ChooseBackupOption, SeedPhrase, ScanPrimary, AddBackupDevice, Finalize, Done + UpgradeWallet, + CreateWallet, + ChooseBackupOption, + SeedPhrase, + ScanPrimary, + AddBackupDevice, + Finalize, + Done, } @JvmInline diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt index 1f4d1ee3a6..9a7b2c1476 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt @@ -14,4 +14,5 @@ fun screenTitleByStep(step: OnboardingMultiWalletState.Step): TextReference = wh OnboardingMultiWalletState.Step.ChooseBackupOption -> resourceReference(R.string.onboarding_getting_started) OnboardingMultiWalletState.Step.Finalize -> resourceReference(R.string.onboarding_button_finalize_backup) OnboardingMultiWalletState.Step.Done -> resourceReference(R.string.common_done) + OnboardingMultiWalletState.Step.UpgradeWallet -> resourceReference(R.string.common_tangem) } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 3257456f21..85b018759e 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -363,32 +363,16 @@ internal class WalletSettingsModel @Inject constructor( if (!state.value.isWalletBackedUp) { messageSender.send(makeBackupAtFirstAlertBS) } else { - val userWallet = getUserWalletUseCase(params.userWalletId) - .getOrElse { error("User wallet with id ${params.userWalletId} not found") } - if (userWallet is UserWallet.Hot) { - val hotWalletId = userWallet.hotWalletId - when (hotWalletId.authType) { - HotWalletId.AuthType.NoPassword -> { - router.push(AppRoute.UpdateAccessCode(params.userWalletId)) - } - HotWalletId.AuthType.Password, - HotWalletId.AuthType.Biometry, - -> modelScope.launch { - unlockHotWalletContextualUseCase.invoke(hotWalletId) - .onLeft { - Timber.e("Unable to unlock wallet with id ${params.userWalletId}") - } - .onRight { - router.push(AppRoute.UpdateAccessCode(params.userWalletId)) - } - } - } + unlockWalletIfNeedAndProceed { + router.push(AppRoute.UpdateAccessCode(params.userWalletId)) } } } private fun onUpgradeWalletClick() { - router.push(AppRoute.UpgradeWallet(params.userWalletId)) + unlockWalletIfNeedAndProceed { + router.push(AppRoute.UpgradeWallet(params.userWalletId)) + } } private fun onDismissUpgradeWalletClick() { @@ -396,4 +380,28 @@ internal class WalletSettingsModel @Inject constructor( dismissUpgradeWalletNotificationUseCase.invoke(params.userWalletId) } } + + private fun unlockWalletIfNeedAndProceed(action: () -> Unit) { + val userWallet = getUserWalletUseCase(params.userWalletId) + .getOrElse { error("User wallet with id ${params.userWalletId} not found") } + if (userWallet is UserWallet.Hot) { + val hotWalletId = userWallet.hotWalletId + when (hotWalletId.authType) { + HotWalletId.AuthType.NoPassword -> { + action() + } + HotWalletId.AuthType.Password, + HotWalletId.AuthType.Biometry, + -> modelScope.launch { + unlockHotWalletContextualUseCase.invoke(hotWalletId) + .onLeft { + Timber.e("Unable to unlock wallet with id ${params.userWalletId}") + } + .onRight { + action() + } + } + } + } + } } \ No newline at end of file From 11511d36dd55dbc9f2d3e6623eec0d13fe6f3d7d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 14:04:16 +0300 Subject: [PATCH 078/132] Updated on 2026-08-14 --- .../tangem/screens/TokenDetailsPageObject.kt | 40 +++++++++++ .../kotlin/com/tangem/tests/SendTest.kt | 69 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index d17b8a09c2..3b9fb1b195 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.test.TokenDetailsScreenTestTags import com.tangem.features.tokendetails.impl.R import com.tangem.core.ui.utils.LazyListItemPositionSemantics @@ -14,6 +15,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import androidx.compose.ui.test.hasText as withText class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -117,6 +119,44 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasText(getResourceString(R.string.common_send)) } + fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child { + hasAnySibling(withText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName))) + hasTestTag(NotificationTestTags.ICON) + useUnmergedTree = true + } + + fun networkFeeNotificationTitle(feeCurrencyName: String): KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName)) + useUnmergedTree = true + } + + fun networkFeeNotificationMessage( + currencyName: String, + networkName: String, + feeCurrencyName: String, + feeCurrencySymbol: String, + ): KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText( + getResourceString( + R.string.warning_send_blocked_funds_for_fee_message, + currencyName, + networkName, + currencyName, + feeCurrencyName, + feeCurrencySymbol + ) + ) + useUnmergedTree = true + } + + fun goToBuyCurrencyButton(feeCurrencySymbol: String): KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_buy_currency, feeCurrencySymbol)) + useUnmergedTree = true + } + } internal fun BaseTestCase.onTokenDetailsScreen(function: TokenDetailsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt new file mode 100644 index 0000000000..7cf0a321e3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt @@ -0,0 +1,69 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import com.tangem.screens.onTokenDetailsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SendTest : BaseTestCase() { + + @AllureId("3645") + @DisplayName("Send: check fee notification") + @Test + fun checkFeeNotificationTest() { + val currencyName = "POL (ex-MATIC)" + val feeCurrencyName = "Ethereum" + val feeCurrencySymbol = "ETH" + val balance = "$763.55" + val scenarioName = "eth_network_balance" + val scenarioState = "Empty" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Click on token with name: $currencyName") { + onMainScreen { tokenWithTitleAndAddress(currencyName).clickWithAssertion() } + } + step("Assert 'Insufficient $feeCurrencyName to cover network fee' notification icon is displayed") { + onTokenDetailsScreen { networkFeeNotificationIcon(feeCurrencyName).assertIsDisplayed() } + } + step("Assert 'Insufficient $feeCurrencyName to cover network fee' notification title is displayed") { + onTokenDetailsScreen { networkFeeNotificationTitle(feeCurrencyName).assertIsDisplayed() } + } + step("Assert 'Insufficient $feeCurrencyName to cover network fee' notification text is displayed") { + onTokenDetailsScreen { + networkFeeNotificationMessage( + currencyName, + feeCurrencyName, + feeCurrencyName, + feeCurrencySymbol + ).assertIsDisplayed() + } + } + step("Assert 'Go to $feeCurrencySymbol' button is displayed") { + onTokenDetailsScreen { goToBuyCurrencyButton(feeCurrencySymbol).assertIsDisplayed() } + } + } + } +} \ No newline at end of file From 28b4c29befabd09f4e0a81545582942d9136606f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 16:15:18 +0400 Subject: [PATCH 079/132] Updated on 2026-08-14 --- .../tangem/common/ui/account/AccountNameUM.kt | 56 ++++++++++++++++ .../models/account/WalletAccountDTO.kt | 2 +- .../account/converter/AccountConvertersExt.kt | 7 -- .../account/converter/AccountNameConverter.kt | 31 +++++++++ .../converter/ArchivedAccountConverter.kt | 2 +- .../converter/CryptoPortfolioConverter.kt | 4 +- .../SaveWalletAccountsResponseConverter.kt | 2 +- .../account/converter/AccountConverterExt.kt | 3 +- .../converter/AccountNameConverterTest.kt | 65 +++++++++++++++++++ .../converter/ArchivedAccountConverterTest.kt | 10 ++- .../converter/CryptoPortfolioConverterTest.kt | 8 +++ ...SaveWalletAccountsResponseConverterTest.kt | 3 +- .../domain/account/models/AccountList.kt | 19 ++++-- .../domain/account/models/AccountListTest.kt | 2 +- .../tangem/domain/models/account/Account.kt | 2 +- .../domain/models/account/AccountName.kt | 56 ++++++++++------ .../domain/models/account/AccountNameTest.kt | 11 ---- .../domain/models/account/AccountTest.kt | 2 +- .../archived/ArchivedAccountListModel.kt | 3 +- .../createedit/AccountCreateEditModel.kt | 15 +++-- .../createedit/entity/AccountCreateEditUM.kt | 5 +- .../entity/AccountCreateEditUMBuilder.kt | 9 +-- .../createedit/ui/AccountCreateEditContent.kt | 33 ++++++---- .../account/details/AccountDetailsModel.kt | 2 +- .../details/entity/AccountDetailsUM.kt | 3 +- .../details/ui/AccountDetailsContent.kt | 4 +- .../utils/AccountItemsDelegate.kt | 3 +- 27 files changed, 278 insertions(+), 84 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/AccountNameConverter.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt new file mode 100644 index 0000000000..e86dda636b --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt @@ -0,0 +1,56 @@ +package com.tangem.common.ui.account + +import androidx.compose.runtime.Immutable +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.AccountName + +/** + * Represents a user model (UM) for an [AccountName] in the UI layer. + * This sealed interface provides a way to handle different types of account names. + */ +@Immutable +sealed interface AccountNameUM { + + /** The textual representation of the account name */ + val value: TextReference + + /** + * Represents the default main account name. + * If the user renames the main account, it will be converted to a [Custom] account name. + */ + data object DefaultMain : AccountNameUM { + + override val value: TextReference = resourceReference(R.string.account_main_account_title) + } + + /** + * Represents a custom account name provided by the user + * + * @property raw the raw string value of the custom account name + */ + class Custom(internal val raw: String) : AccountNameUM { + + override val value: TextReference = stringReference(value = raw) + } +} + +/** Extension function to convert a domain model [AccountName] to its corresponding UI model [AccountNameUM] */ +fun AccountName.toUM(): AccountNameUM { + return when (this) { + is AccountName.Custom -> AccountNameUM.Custom(raw = value) + AccountName.DefaultMain -> AccountNameUM.DefaultMain + } +} + +/** Extension function to convert a UI model [AccountNameUM] to its corresponding domain model [AccountName] */ +fun AccountNameUM.toDomain(): Either = either { + when (this@toDomain) { + is AccountNameUM.Custom -> AccountName(value = raw).bind() + AccountNameUM.DefaultMain -> AccountName.DefaultMain + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt index 343b6cdf2a..2e9e49b4af 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/WalletAccountDTO.kt @@ -7,7 +7,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @JsonClass(generateAdapter = true) data class WalletAccountDTO( @Json(name = "id") val id: String, - @Json(name = "name") val name: String, + @Json(name = "name") val name: String?, @Json(name = "derivation") val derivationIndex: Int, @Json(name = "icon") val icon: String, @Json(name = "iconColor") val iconColor: String, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt index ab8eef91a8..3d35e7327c 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt @@ -3,7 +3,6 @@ package com.tangem.data.account.converter import arrow.core.getOrElse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.models.account.AccountId -import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId @@ -14,12 +13,6 @@ internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { } } -internal fun String.toAccountName(): AccountName { - return AccountName(value = this).getOrElse { - error("Unable to create AccountName from value: $this. Cause: $it") - } -} - internal fun WalletAccountDTO.toIcon(): CryptoPortfolioIcon { return CryptoPortfolioIconConverter.convert( value = CryptoPortfolioIconConverter.DataModel(icon = icon, color = iconColor), diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountNameConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountNameConverter.kt new file mode 100644 index 0000000000..d4d20d7364 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountNameConverter.kt @@ -0,0 +1,31 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.domain.models.account.AccountName +import com.tangem.utils.converter.TwoWayConverter + +/** + * A converter for transforming [AccountName] domain models into their string representations. + * This is used to handle the conversion logic between the domain layer and other layers. + * +[REDACTED_AUTHOR] + */ +internal object AccountNameConverter : TwoWayConverter { + + override fun convert(value: AccountName): String? { + return when (value) { + is AccountName.Custom -> value.value + AccountName.DefaultMain -> null + } + } + + override fun convertBack(value: String?): AccountName { + return if (value == null) { + AccountName.DefaultMain + } else { + AccountName(value = value).getOrElse { + error("Unable to create AccountName from value: $value. Cause: $it") + } + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt index 072c82e3fc..764ede3152 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt @@ -19,7 +19,7 @@ internal class ArchivedAccountConverter( override fun convert(value: WalletAccountDTO): ArchivedAccount { return ArchivedAccount( accountId = value.id.toAccountId(userWalletId = userWalletId), - name = value.name.toAccountName(), + name = AccountNameConverter.convertBack(value = value.name), icon = value.toIcon(), derivationIndex = value.derivationIndex.toDerivationIndex(), tokensCount = value.totalTokens ?: error("Total tokens should not be null"), diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt index bae26707a6..0117b2b653 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt @@ -29,7 +29,7 @@ internal class CryptoPortfolioConverter @AssistedInject constructor( return Account.CryptoPortfolio( accountId = value.id.toAccountId(userWallet.walletId), - accountName = value.name.toAccountName(), + accountName = AccountNameConverter.convertBack(value = value.name), icon = value.toIcon(), derivationIndex = value.derivationIndex.toDerivationIndex(), cryptoCurrencies = if (tokens.isNotEmpty()) { @@ -46,7 +46,7 @@ internal class CryptoPortfolioConverter @AssistedInject constructor( override fun convertBack(value: Account.CryptoPortfolio): WalletAccountDTO { return WalletAccountDTO( id = value.accountId.value, - name = value.accountName.value, + name = AccountNameConverter.convert(value = value.accountName), derivationIndex = value.derivationIndex.value, icon = value.icon.value.name, iconColor = value.icon.color.name, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt index 25c5307b84..07c76c7c24 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt @@ -24,7 +24,7 @@ internal object SaveWalletAccountsResponseConverter : Converter { + return listOf( + ConvertModel( + value = AccountName.Custom("MyAccount").getOrNull()!!, + expected = "MyAccount", + ), + ConvertModel(value = AccountName.DefaultMain, expected = null), + ) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = AccountNameConverter.convertBack(value = model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel(value = "MyAccount", expected = AccountName.Custom("MyAccount").getOrNull()!!), + ConvertBackModel(value = null, expected = AccountName.DefaultMain), + ) + } + } + + data class ConvertModel(val value: AccountName, val expected: String?) + + data class ConvertBackModel(val value: String?, val expected: AccountName) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt index 90831c1d3b..bf08d5b7d9 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt @@ -54,6 +54,12 @@ class ArchivedAccountConverterTest { ), ), ), + TestModel( + value = createDTO(name = null), + expected = Result.success( + createDomain().copy(name = AccountName.DefaultMain), + ), + ), TestModel( value = createDTO(name = ""), expected = Result.failure( @@ -104,7 +110,7 @@ class ArchivedAccountConverterTest { private fun createDTO( accountId: String = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027", - name: String = "Test Account", + name: String? = "Test Account", icon: String = "Letter", iconColor: String = "Azure", derivationIndex: Int = 0, @@ -126,7 +132,7 @@ class ArchivedAccountConverterTest { private fun createDomain(): ArchivedAccount { return ArchivedAccount( accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(0).getOrNull()!!), - name = "Test Account".toAccountName(), + name = AccountName("Test Account").getOrNull()!!, derivationIndex = 0.toDerivationIndex(), icon = CryptoPortfolioIcon.ofCustomAccount( value = CryptoPortfolioIcon.Icon.Letter, diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt index f046bae96a..7e3e41a243 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt @@ -75,6 +75,14 @@ class CryptoPortfolioConverterTest { ), ), ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = null), + expected = Result.success( + createCryptoPortfolio(userWalletId = userWallet.walletId).copy( + accountName = AccountName.DefaultMain, + ), + ), + ), ConvertModel( value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = ""), expected = Result.failure( diff --git a/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt index 13ad114fe4..6d5ebee814 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt @@ -5,6 +5,7 @@ import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsRes import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.account.models.AccountList import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.every @@ -37,7 +38,7 @@ class SaveWalletAccountsResponseConverterTest { accounts = listOf( WalletAccountDTO( id = accountList.mainAccount.accountId.value, - name = accountList.mainAccount.accountName.value, + name = (accountList.mainAccount.accountName as? AccountName.Custom)?.value, derivationIndex = accountList.mainAccount.derivationIndex.value, icon = accountList.mainAccount.icon.value.name, iconColor = accountList.mainAccount.icon.color.name, diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index 89f7fd33c8..0a5b08f87b 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -6,6 +6,7 @@ import arrow.core.raise.ensure import com.tangem.domain.models.TokensGroupType import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.utils.extensions.addOrReplace @@ -119,8 +120,8 @@ data class AccountList private constructor( } @Serializable - data object DuplicateAccountNames : Error { - override fun toString(): String = "$tag: Account list contains duplicate account names" + data class DuplicateAccountNames(val message: String) : Error { + override fun toString(): String = "$tag: Account list contains duplicate account names. $message" } } @@ -160,8 +161,18 @@ data class AccountList private constructor( val uniqueAccountIdsCount = accounts.map { it.accountId.value }.distinct().size ensure(accounts.size == uniqueAccountIdsCount) { Error.DuplicateAccountIds } - val uniqueAccountNameCount = accounts.map { it.accountName.value }.distinct().size - ensure(accounts.size == uniqueAccountNameCount) { Error.DuplicateAccountNames } + val defaultMainNameCount = accounts.count { it.accountName is AccountName.DefaultMain } + + val customNames = accounts.mapNotNull { (it.accountName as? AccountName.Custom)?.value } + val uniqueCustomNameCount = customNames.distinct().size + + ensure(defaultMainNameCount == 0 || defaultMainNameCount == 1) { + Error.DuplicateAccountNames("Only one account can have the default main name.") + } + + ensure(customNames.size == uniqueCustomNameCount) { + Error.DuplicateAccountNames("Custom account names must be unique.") + } AccountList( userWallet = userWallet, diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt index 0c43198fa0..4ed41500f0 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt @@ -157,7 +157,7 @@ class AccountListTest { createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 0), createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 1), ), - expected = AccountList.Error.DuplicateAccountNames.left(), + expected = AccountList.Error.DuplicateAccountNames("Custom account names must be unique.").left(), ), ) } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt index b376be0db1..d3962fbd50 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt @@ -162,7 +162,7 @@ sealed interface Account { userWalletId = userWalletId, derivationIndex = derivationIndex, ), - accountName = AccountName.Main, + accountName = AccountName.DefaultMain, icon = CryptoPortfolioIcon.ofMainAccount(userWalletId), derivationIndex = derivationIndex, cryptoCurrencies = cryptoCurrencies, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt index 29fe653a8b..ead33f51a3 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt @@ -8,14 +8,44 @@ import kotlinx.serialization.Serializable /** * Represents an account name * - * @property value the validated account name as a string - * [REDACTED_AUTHOR] */ @Serializable -data class AccountName private constructor( - val value: String, -) { +sealed interface AccountName { + + /** + * Represents the default main account name. + * If the user renames the main account, it will be converted to a [Custom] account name. + */ + @Serializable + data object DefaultMain : AccountName + + /** + * Represents a custom account name provided by the user + * + * @property value the string value of the custom account name + */ + @Serializable + data class Custom private constructor(val value: String) : AccountName { + + companion object { + + /** + * Factory method to create an [AccountName.Custom] instance. + * Validates the input string to ensure it is not blank and does not exceed the maximum length. + * + * @param value the input string representing the account name + */ + operator fun invoke(value: String): Either = either { + val trimmedValue = value.trim() + + ensure(trimmedValue.isNotBlank()) { Error.Empty } + ensure(trimmedValue.length <= MAX_LENGTH) { Error.ExceedsMaxLength } + + Custom(value = trimmedValue) + } + } + } /** * Represents possible validation errors @@ -44,26 +74,14 @@ data class AccountName private constructor( companion object { - private const val MAIN_ACCOUNT_NAME = "Main Account" private const val MAX_LENGTH = 20 - /** Default name for the main account */ - val Main: AccountName - get() = AccountName(value = MAIN_ACCOUNT_NAME) - /** - * Factory method to create an `AccountName` instance. + * Factory method to create an [AccountName] instance. * Validates the input string to ensure it is not blank and does not exceed the maximum length. * * @param value the input string representing the account name */ - operator fun invoke(value: String): Either = either { - val trimmedValue = value.trim() - - ensure(trimmedValue.isNotBlank()) { Error.Empty } - ensure(trimmedValue.length <= MAX_LENGTH) { Error.ExceedsMaxLength } - - AccountName(value = trimmedValue) - } + operator fun invoke(value: String): Either = Custom(value) } } \ No newline at end of file diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt index e2aee138be..d68bc46ab9 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt @@ -3,7 +3,6 @@ package com.tangem.domain.models.account import arrow.core.Either import arrow.core.left import com.google.common.truth.Truth -import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -14,16 +13,6 @@ import org.junit.jupiter.params.provider.MethodSource @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AccountNameTest { - @Test - fun main_returnsMainAccountName() { - // Act - val main = AccountName.Main.value - - // Assert - val expected = "Main Account" - Truth.assertThat(main).isEqualTo(expected) - } - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: InvokeTestModel) { diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt index 91baaf2a76..9578f81fb1 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountTest.kt @@ -147,7 +147,7 @@ class AccountTest { userWalletId = userWalletId, derivationIndex = derivationIndex, ), - accountName = AccountName.Main, + accountName = AccountName.DefaultMain, icon = CryptoPortfolioIcon.ofMainAccount(userWalletId), derivationIndex = derivationIndex, cryptoCurrencies = emptySet(), diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index 95554e161b..f170b6653b 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.account.archived +import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router @@ -50,7 +51,7 @@ internal class ArchivedAccountListModel @Inject constructor( title = resourceReference(R.string.account_archived_recover_dialog_title), message = resourceReference( id = R.string.account_archived_recover_dialog_description, - formatArgs = wrappedList(account.accountName.value), + formatArgs = wrappedList(account.accountName.toUM().value), ), firstActionBuilder = { firstAction }, secondActionBuilder = { secondAction }, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt index 03934e051e..a7cdd84c16 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt @@ -1,6 +1,8 @@ package com.tangem.features.account.createedit +import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.toDomain +import com.tangem.common.ui.account.toUM import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped @@ -16,7 +18,6 @@ import com.tangem.core.ui.utils.showErrorDialog import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase -import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId @@ -92,7 +93,7 @@ internal class AccountCreateEditModel @Inject constructor( private suspend fun createNewCryptoPortfolio(params: AccountCreateEditComponent.Params.Create) { val state = uiState.value - val name = AccountName(value = state.account.name).getOrNull() ?: return + val name = state.account.name.toDomain().getOrNull() ?: return val icon = state.account.portfolioIcon.toDomain() val index = state.account.derivationInfo.index ?: return val derivationIndex = DerivationIndex(value = index).getOrNull() ?: return @@ -107,7 +108,7 @@ internal class AccountCreateEditModel @Inject constructor( private suspend fun editCryptoPortfolio(params: AccountCreateEditComponent.Params.Edit) { val state = uiState.value - val name = AccountName(state.account.name).getOrNull() ?: return + val name = state.account.name.toDomain().getOrNull() ?: return val icon = state.account.portfolioIcon.toDomain() val isNewName = name != params.account.accountName val isNewIcon = icon != params.account.portfolioIcon @@ -132,18 +133,20 @@ internal class AccountCreateEditModel @Inject constructor( .validateNewState() } - private fun onNameChange(name: String) { + private fun onNameChange(name: AccountNameUM) { uiState.value = uiState.value .updateName(name) .validateNewState() } private fun AccountCreateEditUM.validateNewState(): AccountCreateEditUM { - val isValidName = AccountName(this.account.name).isRight() + val isValidName = this.account.name.toDomain().isRight() val isAvailableForConfirm = when (params) { is AccountCreateEditComponent.Params.Create -> isValidName is AccountCreateEditComponent.Params.Edit -> { - val isNewName = this.account.name != params.account.accountName.value + val oldName = params.account.accountName.toUM() + + val isNewName = this.account.name != oldName val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon isValidName && (isNewName || isNewIcon) } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt index 4b4365ba95..b9d22c8aea 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt @@ -1,5 +1,6 @@ package com.tangem.features.account.createedit.entity +import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon @@ -15,11 +16,11 @@ internal data class AccountCreateEditUM( ) { data class Account( - val name: String, + val name: AccountNameUM, val portfolioIcon: CryptoPortfolioIconUM, val derivationInfo: DerivationInfo, val inputPlaceholder: TextReference, - val onNameChange: (String) -> Unit, + val onNameChange: (AccountNameUM) -> Unit, ) sealed interface DerivationInfo { diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt index 24b69681c9..87947361a4 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt @@ -1,5 +1,6 @@ package com.tangem.features.account.createedit.entity +import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.toUM import com.tangem.core.res.R import com.tangem.core.ui.extensions.TextReference @@ -24,17 +25,17 @@ internal class AccountCreateEditUMBuilder( is AccountCreateEditComponent.Params.Edit -> resourceReference(R.string.account_form_title_edit) } - fun initAccountUM(onNameChange: (String) -> Unit): AccountCreateEditUM.Account { + fun initAccountUM(onNameChange: (AccountNameUM) -> Unit): AccountCreateEditUM.Account { return when (params) { is AccountCreateEditComponent.Params.Create -> AccountCreateEditUM.Account( - name = "", + name = AccountNameUM.Custom(raw = ""), portfolioIcon = createIcon, derivationInfo = AccountCreateEditUM.DerivationInfo.Empty, inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account), onNameChange = onNameChange, ) is AccountCreateEditComponent.Params.Edit -> AccountCreateEditUM.Account( - name = params.account.accountName.value, + name = params.account.accountName.toUM(), portfolioIcon = params.account.portfolioIcon.toUM(), derivationInfo = createAccountDerivationInfo( index = (params.account as Account.CryptoPortfolio).derivationIndex.value, @@ -108,7 +109,7 @@ internal class AccountCreateEditUMBuilder( ) } - fun AccountCreateEditUM.updateName(name: String): AccountCreateEditUM { + fun AccountCreateEditUM.updateName(name: AccountNameUM): AccountCreateEditUM { return this.copy(account = this.account.copy(name = name)) } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt index 639b44419e..2b7635a7df 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,11 +25,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.common.ui.R -import com.tangem.common.ui.account.AccountIcon -import com.tangem.common.ui.account.AccountIconPreviewData -import com.tangem.common.ui.account.AccountIconSize -import com.tangem.common.ui.account.getResId -import com.tangem.common.ui.account.getUiColor +import com.tangem.common.ui.account.* import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH24 @@ -43,7 +40,6 @@ import com.tangem.features.account.createedit.entity.AccountCreateEditUM import com.tangem.features.account.createedit.entity.AccountCreateEditUM.Account import kotlinx.collections.immutable.toImmutableList -@Suppress("LongMethod", "MagicNumber") @Composable internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modifier = Modifier) { Column( @@ -65,7 +61,6 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi modifier = Modifier .padding(horizontal = 16.dp) .weight(1f), - ) { AccountSummary(state.account) SpacerH24() @@ -103,7 +98,7 @@ private fun AccountSummary(account: Account) { Spacer(modifier = Modifier.height(24.dp)) AccountIcon( - name = stringReference(account.name), + name = account.name.value, icon = account.portfolioIcon, size = AccountIconSize.Large, ) @@ -116,13 +111,27 @@ private fun AccountSummary(account: Account) { ) Spacer(modifier = Modifier.height(2.dp)) + val wasDefault = remember { account.name is AccountNameUM.DefaultMain } + val defaultAccountName = AccountNameUM.DefaultMain.value.resolveReference() AutoSizeTextField( centered = true, textStyle = TangemTheme.typography.head, placeholder = account.inputPlaceholder, - value = account.name, + value = account.name.value.resolveReference(), singleLine = true, - onValueChange = account.onNameChange, + onValueChange = { + /* + * If the user had the default main account name and enters the same name during renaming, + * we should use the default value instead of custom to avoid breaking the name validation process. + */ + val newName = if (wasDefault && it == defaultAccountName) { + AccountNameUM.DefaultMain + } else { + AccountNameUM.Custom(raw = it) + } + + account.onNameChange(newName) + }, ) SpacerH(20.dp) } @@ -279,7 +288,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider Unit, val onAccountEditClick: () -> Unit, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt index 7f3c86da48..510f776f53 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -143,7 +143,7 @@ private fun AccountRow(state: AccountDetailsUM) { horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { AccountRow( - title = stringReference(state.accountName), + title = state.accountName, subtitle = resourceReference(R.string.account_form_name), icon = state.accountIcon, modifier = Modifier.weight(1f), @@ -177,7 +177,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider Date: Thu, 11 Sep 2025 14:47:47 +0300 Subject: [PATCH 080/132] Updated on 2026-08-14 --- .../hotwallet/accesscode/AccessCodeModel.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt index 404b5091bb..ede26e72e0 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt @@ -25,6 +25,7 @@ import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.UnlockHotWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -32,6 +33,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber import javax.inject.Inject +import kotlin.coroutines.resume @Suppress("LongParameterList") @Stable @@ -148,6 +150,7 @@ internal class AccessCodeModel @Inject constructor( } } + @OptIn(ExperimentalCoroutinesApi::class) private suspend fun tryToAskForBiometry() { if (!shouldAskForBiometry()) return @@ -163,7 +166,9 @@ internal class AccessCodeModel @Inject constructor( setAskBiometryShownUseCase() walletsRepository.setUseBiometricAuthentication(true) walletsRepository.setRequireAccessCode(false) - continuation.resumeWith(Result.success(Unit)) + if (continuation.isActive) { + continuation.resume(Unit) + } } }, ), @@ -172,13 +177,17 @@ internal class AccessCodeModel @Inject constructor( onClick = { modelScope.launch { setAskBiometryShownUseCase() - continuation.resumeWith(Result.success(Unit)) + if (continuation.isActive) { + continuation.resume(Unit) + } } }, ), onDismissRequest = { modelScope.launch { - continuation.resumeWith(Result.success(Unit)) + if (continuation.isActive) { + continuation.resume(Unit) + } } }, ), From 16b33742cadc549dbdf27094007e48c7249e309a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 10:15:44 +0400 Subject: [PATCH 081/132] Updated on 2026-08-14 --- .../api/tangemTech/TangemTechApi.kt | 10 +- .../tangem/datasource/utils/DataStoreExt.kt | 6 + .../fetcher/DefaultWalletAccountsFetcher.kt | 163 +++++++ .../DefaultAccountsCRUDRepository.kt | 4 +- .../account/converter/AccountConverterExt.kt | 3 +- .../DefaultWalletAccountsFetcherTest.kt | 460 ++++++++++++++++++ .../DefaultAccountsCRUDRepositoryTest.kt | 8 +- .../common/account/WalletAccountsFetcher.kt | 15 + .../common/account/WalletAccountsSaver.kt | 29 ++ 9 files changed, 686 insertions(+), 12 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/utils/DataStoreExt.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt create mode 100644 data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt create mode 100644 data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 9a557cd922..f98dfae648 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -119,9 +119,6 @@ interface TangemTechApi { @Path("application_id") applicationId: String, @Body body: NotificationApplicationCreateBody, ): ApiResponse - - @PATCH("v1/user-wallets/wallets/{wallet_id}/notify") - suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse // endregion // region user-wallets @@ -148,12 +145,15 @@ interface TangemTechApi { // region account @GET("/v1/wallets/{walletId}/accounts") - suspend fun getWalletAccounts(@Path("walletId") walletId: String): ApiResponse + suspend fun getWalletAccounts( + @Path("walletId") walletId: String, + @Header("If-None-Match") eTag: String? = null, + ): ApiResponse @PUT("/v1/wallets/{walletId}/accounts") suspend fun saveWalletAccounts( @Path("walletId") walletId: String, - @Header("If-Match") ifMatch: String, + @Header("If-Match") eTag: String, @Body body: SaveWalletAccountsResponse, ): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/DataStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/DataStoreExt.kt new file mode 100644 index 0000000000..a8ecd2a3e2 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/DataStoreExt.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.utils + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.firstOrNull + +suspend fun DataStore.getSyncOrNull(): T? = data.firstOrNull() \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt new file mode 100644 index 0000000000..45a16f947b --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -0,0 +1,163 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER +import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.utils.getSyncOrNull +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +/** + * Default implementation of [WalletAccountsFetcher] and [WalletAccountsSaver] + * + * @property tangemTechApi API for network requests + * @property accountsResponseStoreFactory factory to create [AccountsResponseStore] + * @property userTokensSaver saves user tokens to the database + * @property fetchWalletAccountsErrorHandler handles errors during fetching wallet accounts + * @property eTagsStore store for ETags to manage caching + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultWalletAccountsFetcher( + private val tangemTechApi: TangemTechApi, + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val userTokensSaver: UserTokensSaver, + private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler, + private val eTagsStore: ETagsStore, + private val dispatchers: CoroutineDispatcherProvider, +) : WalletAccountsFetcher, WalletAccountsSaver { + + override suspend fun fetch(userWalletId: UserWalletId) { + val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() + val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse) + val unassignedTokens = accountsResponse?.unassignedTokens + + if (!unassignedTokens.isNullOrEmpty()) { + assignTokens(userWalletId, accountsResponse) + } + } + + override suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { + push(userWalletId = userWalletId, accounts = response.accounts) + store(userWalletId = userWalletId, response = response) + } + + override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { + val store = getAccountsResponseStore(userWalletId = userWalletId) + + store.updateData { response } + } + + override suspend fun push(userWalletId: UserWalletId, accounts: List) { + push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts)) + } + + override suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) { + safeApiCall( + call = { + var eTag = getETag(userWalletId) + + if (eTag == null) { + fetch(userWalletId) + + eTag = getETag(userWalletId) ?: error("ETag is null after fetch") + } + + val apiResponse = withContext(dispatchers.io) { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = body, + ) + } + + saveETag(userWalletId, apiResponse) + + apiResponse.bind() + }, + onError = { error -> + if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) { + throw error + } + }, + ) + } + + private suspend fun fetchWalletAccounts( + userWalletId: UserWalletId, + savedAccountsResponse: GetWalletAccountsResponse?, + ): GetWalletAccountsResponse? { + return safeApiCall( + call = { + val apiResponse = withContext(dispatchers.io) { + tangemTechApi.getWalletAccounts( + walletId = userWalletId.stringValue, + eTag = getETag(userWalletId), + ) + } + + saveETag(userWalletId, apiResponse) + + val responseBody = apiResponse.bind() + store(userWalletId = userWalletId, response = responseBody) + + responseBody + }, + onError = { + // pushWalletAccounts and storeWalletAccounts help to avoid cyclic dependency + fetchWalletAccountsErrorHandler.handle( + error = it, + userWalletId = userWalletId, + savedAccountsResponse = savedAccountsResponse, + pushWalletAccounts = ::push, + storeWalletAccounts = ::store, + ) + + null + }, + ) + } + + private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { + val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId) + + pushAndStore(userWalletId = userWalletId, response = accountsResponseWithTokens) + + userTokensSaver.push( + userWalletId = userWalletId, + response = accountsResponseWithTokens.toUserTokensResponse(), + ) + } + + private suspend fun getETag(userWalletId: UserWalletId): String? { + return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + } + + private suspend fun saveETag(userWalletId: UserWalletId, apiResponse: ApiResponse<*>) { + val eTag = apiResponse.headers[IF_NONE_MATCH_HEADER]?.firstOrNull() + + if (eTag != null) { + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag) + } + } + + private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore { + return accountsResponseStoreFactory.create(userWalletId = userWalletId) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index 333001ed0e..73a4766449 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -100,13 +100,13 @@ internal class DefaultAccountsCRUDRepository( saveAccountsMutex.withLock { val store = getAccountsResponseStore(userWalletId = accountList.userWallet.walletId) - val version = store.data.firstOrNull()?.wallet?.version ?: 0 + store.data.firstOrNull()?.wallet?.version ?: 0 val body = SaveWalletAccountsResponseConverter.convert(value = accountList) withContext(dispatchers.io) { tangemTechApi.saveWalletAccounts( walletId = accountList.userWallet.walletId.stringValue, - ifMatch = version.toString(), + eTag = "", // TODO("[REDACTED_JIRA]") body = body, ) .getOrThrow() diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt index 16dfff6ac7..880b20520e 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt @@ -46,6 +46,7 @@ internal fun createGetWalletAccountsResponse( iconColor: String? = null, derivationIndex: Int? = null, tokens: List? = emptyList(), + unassignedTokens: List = emptyList(), ): GetWalletAccountsResponse { return GetWalletAccountsResponse( wallet = GetWalletAccountsResponse.Wallet( @@ -66,7 +67,7 @@ internal fun createGetWalletAccountsResponse( ) .let(::add) }, - unassignedTokens = emptyList(), + unassignedTokens = unassignedTokens, ) } diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt new file mode 100644 index 0000000000..d90aa7727c --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -0,0 +1,460 @@ +package com.tangem.data.account.fetcher + +import com.google.common.truth.Truth +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultWalletAccountsFetcherTest { + + private val tangemTechApi: TangemTechApi = mockk() + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) + private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) + private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true) + + private val fetcher: DefaultWalletAccountsFetcher = DefaultWalletAccountsFetcher( + tangemTechApi = tangemTechApi, + accountsResponseStoreFactory = accountsResponseStoreFactory, + userTokensSaver = userTokensSaver, + fetchWalletAccountsErrorHandler = fetchWalletAccountsErrorHandler, + eTagsStore = eTagsStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + private val eTag = "etag" + + @BeforeAll + fun setUp() { + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + + coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + } + + @AfterEach + fun tearDown() { + clearMocks( + tangemTechApi, + userTokensSaver, + fetchWalletAccountsErrorHandler, + ) + + accountsResponseStoreFlow.value = null + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Fetch { + + @Test + fun `fetch should call assignTokens when unassignedTokens are not empty`() = runTest { + // Arrange + val savedAccountsResponse = null + val unassignedToken = createToken(accountId = null) + + val accountsResponse = createGetWalletAccountsResponse( + userWalletId = userWalletId, + unassignedTokens = listOf(unassignedToken), + ) + val newETag = "newEtag" + val apiResponse = ApiResponse.Success( + data = accountsResponse, + headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)), + ) + + accountsResponseStoreFlow.value = savedAccountsResponse + + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val updatedAccountsResponse = accountsResponse.copy( + accounts = accountsResponse.accounts.map { + it.copy(tokens = listOf(unassignedToken.copy(accountId = accountId))) + }, + unassignedTokens = emptyList(), + ) + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiResponse + + coEvery { accountsResponseStore.updateData(any()) } returns accountsResponse + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + } returns ApiResponse.Success(data = Unit) + + // Act + fetcher.fetch(userWalletId) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + } + + coVerify(inverse = true) { + fetchWalletAccountsErrorHandler.handle( + error = any(), + userWalletId = any(), + savedAccountsResponse = any(), + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } + } + + @Test + fun `fetch should not call assignTokens when unassignedTokens are empty`() = runTest { + // Arrange + val savedAccountsResponse = null + val accountsResponse = createGetWalletAccountsResponse( + userWalletId = userWalletId, + unassignedTokens = emptyList(), + ) + val newETag = "newEtag" + val apiResponse = ApiResponse.Success( + data = accountsResponse, + headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)), + ) + + accountsResponseStoreFlow.value = savedAccountsResponse + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiResponse + + coEvery { accountsResponseStore.updateData(any()) } returns accountsResponse + + // Act + fetcher.fetch(userWalletId) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + } + + coVerify(inverse = true) { + fetchWalletAccountsErrorHandler.handle( + error = any(), + userWalletId = any(), + savedAccountsResponse = any(), + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + + tangemTechApi.saveWalletAccounts(walletId = any(), eTag = any(), body = any()) + userTokensSaver.push(any(), any()) + } + } + + @Test + fun `fetch should call error handler when getWalletAccounts returns error`() = runTest { + // Arrange + val savedAccountsResponse = null + val apiError = ApiResponse.Error(ApiResponseError.NetworkException) + + accountsResponseStoreFlow.value = savedAccountsResponse + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiError as ApiResponse + + // Act + fetcher.fetch(userWalletId) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + + fetchWalletAccountsErrorHandler.handle( + error = apiError.cause, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } + + coVerify(inverse = true) { + eTagsStore.store(userWalletId = any(), key = any(), value = any()) + tangemTechApi.saveWalletAccounts(walletId = any(), eTag = any(), body = any()) + userTokensSaver.push(userWalletId = any(), response = any()) + } + } + + @Test + fun `push should throw error when saveWalletAccounts returns PRECONDITION_FAILED`() = runTest { + // Arrange + val savedAccountsResponse = null + val unassignedToken = createToken(accountId = null) + + val accountsResponse = createGetWalletAccountsResponse( + userWalletId = userWalletId, + unassignedTokens = listOf(unassignedToken), + ) + + val newETag = "newEtag" + val apiResponse = ApiResponse.Success( + data = accountsResponse, + headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)), + ) + + val apiError = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.PRECONDITION_FAILED, + message = null, + errorBody = null, + ) + val saveApiResponse = ApiResponse.Error(apiError) + + accountsResponseStoreFlow.value = savedAccountsResponse + + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val updatedAccountsResponse = accountsResponse.copy( + accounts = accountsResponse.accounts.map { + it.copy(tokens = listOf(unassignedToken.copy(accountId = accountId))) + }, + unassignedTokens = emptyList(), + ) + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns apiResponse + + coEvery { accountsResponseStore.updateData(any()) } returns accountsResponse + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + } returns saveApiResponse as ApiResponse + + // Act + val actual = runCatching { fetcher.fetch(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isEqualTo(apiError) + + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.updateData(any()) + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), + ) + } + + coVerify(inverse = true) { + fetchWalletAccountsErrorHandler.handle( + error = any(), + userWalletId = any(), + savedAccountsResponse = any(), + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Store { + + @Test + fun `store should update data in AccountsResponseStore`() = runTest { + // Arrange + val response = createGetWalletAccountsResponse(userWalletId = userWalletId) + coEvery { accountsResponseStore.updateData(any()) } returns response + + // Act + fetcher.store(userWalletId = userWalletId, response = response) + + // Assert + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.updateData(any()) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Push { + + @Test + fun `push should call saveWalletAccounts with correct params`() = runTest { + // Arrange + val accounts = listOf( + mockk(), + ) + val response = SaveWalletAccountsResponse(accounts) + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = response, + ) + } returns ApiResponse.Success(data = Unit) + + // Act + fetcher.push(userWalletId, response) + + // Assert + coVerify { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = response, + ) + } + } + + @Test + fun `push should throw error when saveWalletAccounts returns PRECONDITION_FAILED`() = runTest { + // Arrange + val accounts = listOf( + mockk(), + ) + val response = SaveWalletAccountsResponse(accounts) + val apiError = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.PRECONDITION_FAILED, + message = null, + errorBody = null, + ) + val saveApiResponse = ApiResponse.Error(apiError) + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = response, + ) + } returns saveApiResponse as ApiResponse + + // Act + val actual = runCatching { fetcher.push(userWalletId, response) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isEqualTo(apiError) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class PushAndStore { + + @Test + fun `pushAndStore should call push and store with correct params`() = runTest { + // Arrange + listOf( + mockk(), + ) + val response = createGetWalletAccountsResponse(userWalletId) + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(accounts = response.accounts), + ) + } returns ApiResponse.Success(data = Unit) + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + fetcher.pushAndStore(userWalletId, response) + + // Assert + coVerifyOrder { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(accounts = response.accounts), + ) + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.updateData(any()) + } + } + } + + private fun createToken( + networkId: String = "ethereum", + derivationPath: String = "m/44'/60'/0'/0/0", + list: List = emptyList(), + name: String = "Ethereum", + symbol: String = "ETH", + decimals: Int = 18, + contractAddress: String? = null, + id: String? = null, + accountId: String? = null, + ) = UserTokensResponse.Token( + id = id, + accountId = accountId, + networkId = networkId, + derivationPath = derivationPath, + name = name, + symbol = symbol, + decimals = decimals, + contractAddress = contractAddress, + addresses = list, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt index 8fbcf92aea..6c620a5b8a 100644 --- a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -585,7 +585,7 @@ class DefaultAccountsCRUDRepositoryTest { coEvery { tangemTechApi.saveWalletAccounts( walletId = userWalletId.stringValue, - ifMatch = version.toString(), + eTag = "", body = body, ) } returns apiResponse @@ -609,7 +609,7 @@ class DefaultAccountsCRUDRepositoryTest { coVerifyOrder { accountsResponseStoreFactory.create(userWalletId) accountsResponseStore.data - tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body) + tangemTechApi.saveWalletAccounts(userWalletId.stringValue, "", body) convertersContainer.getWalletAccountsResponseCF.create(userWallet) converter.convert(accountList) accountsResponseStore.updateData(any()) @@ -638,7 +638,7 @@ class DefaultAccountsCRUDRepositoryTest { coEvery { tangemTechApi.saveWalletAccounts( walletId = userWalletId.stringValue, - ifMatch = version.toString(), + eTag = "", body = body, ) } returns apiResponse @@ -652,7 +652,7 @@ class DefaultAccountsCRUDRepositoryTest { coVerifyOrder { accountsResponseStoreFactory.create(userWalletId) accountsResponseStore.data - tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body) + tangemTechApi.saveWalletAccounts(userWalletId.stringValue, "", body) } coVerify(inverse = true) { diff --git a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt new file mode 100644 index 0000000000..ba81c5db5a --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt @@ -0,0 +1,15 @@ +package com.tangem.data.common.account + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Component for fetching wallet accounts + * +[REDACTED_AUTHOR] + */ +interface WalletAccountsFetcher { + + /** Fetch wallet accounts by [userWalletId] */ + @Throws + suspend fun fetch(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt new file mode 100644 index 0000000000..f4b684613b --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt @@ -0,0 +1,29 @@ +package com.tangem.data.common.account + +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Saver for wallet accounts + * +[REDACTED_AUTHOR] + */ +interface WalletAccountsSaver { + + /** Push and store wallet accounts [response] by [userWalletId] */ + @Throws + suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) + + /** Store wallet accounts [response] by [userWalletId] */ + suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) + + /** Push wallet accounts [body] by [userWalletId] */ + @Throws + suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) + + /** Push wallet accounts [accounts] by [userWalletId] */ + @Throws + suspend fun push(userWalletId: UserWalletId, accounts: List) +} \ No newline at end of file From 3df014362796e5b4b427f7c5af5f4cc23a88f573 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 18:11:39 +0500 Subject: [PATCH 082/132] Updated on 2026-08-14 --- .../com/tangem/core/ui/components/fields/SearchBar.kt | 7 +------ .../choosetoken/ui/ChooseManagedTokenContent.kt | 10 ++++++++++ .../features/managetokens/ui/ManageTokensTopBar.kt | 10 +++++++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt index 2c45500ae0..12847efe53 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -47,10 +46,10 @@ fun SearchBar( modifier: Modifier = Modifier, colors: TextFieldColors = TangemSearchBarDefaults.defaultTextFieldColors, enabled: Boolean = true, + focusRequester: FocusRequester = remember { FocusRequester() }, ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } BasicTextField( @@ -97,10 +96,6 @@ fun SearchBar( ) }, ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } } @Suppress("LongParameterList") diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt index 632f901ca6..615347a07f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/ui/ChooseManagedTokenContent.kt @@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -37,6 +40,8 @@ import kotlinx.collections.immutable.toPersistentList @Composable internal fun ChooseManagedTokenContent(state: ChooseManagedTokenUM, modifier: Modifier = Modifier) { + val focusRequester = remember { FocusRequester() } + Scaffold( modifier = modifier, containerColor = TangemTheme.colors.background.tertiary, @@ -46,6 +51,7 @@ internal fun ChooseManagedTokenContent(state: ChooseManagedTokenUM, modifier: Mo modifier = Modifier.statusBarsPadding(), topBar = state.readContent.topBar, search = state.readContent.search, + focusRequester = focusRequester, ) }, content = { innerPadding -> @@ -57,6 +63,10 @@ internal fun ChooseManagedTokenContent(state: ChooseManagedTokenUM, modifier: Mo ) }, ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } } @Composable diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt index 2b7d67794c..7ea91f6583 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensTopBar.kt @@ -3,7 +3,9 @@ package com.tangem.features.managetokens.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.fields.SearchBar @@ -14,7 +16,12 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM @Composable -internal fun ManageTokensTopBar(topBar: ManageTokensTopBarUM?, search: SearchBarUM, modifier: Modifier = Modifier) { +internal fun ManageTokensTopBar( + topBar: ManageTokensTopBarUM?, + search: SearchBarUM, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { Column( modifier = modifier, ) { @@ -31,6 +38,7 @@ internal fun ManageTokensTopBar(topBar: ManageTokensTopBarUM?, search: SearchBar SearchBar( colors = TangemSearchBarDefaults.secondaryTextFieldColors, state = search, + focusRequester = focusRequester, modifier = Modifier .padding(bottom = TangemTheme.dimens.spacing12) .padding(horizontal = TangemTheme.dimens.spacing16), From c0cf7515b0873e71480cf323ae04c9d21d0625b9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 11:49:16 +0500 Subject: [PATCH 083/132] Updated on 2026-08-14 --- app/build.gradle.kts | 2 ++ .../configs/feature_toggles_config.json | 2 +- .../api/YieldLendingFeatureToggles.kt | 6 ---- .../impl/DefaultYieldLendingFeatureToggles.kt | 11 ------- .../api/.gitignore | 0 .../api/build.gradle.kts | 2 +- .../supply/api/YieldSupplyFeatureToggles.kt | 6 ++++ .../impl/.gitignore | 0 .../impl/build.gradle.kts | 29 ++++++++++++++++--- .../impl/DefaultYieldSupplyFeatureToggles.kt | 11 +++++++ .../impl/di/YieldSupplyFeatureModule.kt} | 12 ++++---- settings.gradle.kts | 4 +-- 12 files changed, 54 insertions(+), 31 deletions(-) delete mode 100644 features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt delete mode 100644 features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt rename features/{yield-lending => yield-supply}/api/.gitignore (100%) rename features/{yield-lending => yield-supply}/api/build.gradle.kts (90%) create mode 100644 features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt rename features/{yield-lending => yield-supply}/impl/.gitignore (100%) rename features/{yield-lending => yield-supply}/impl/build.gradle.kts (55%) create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt rename features/{yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt => yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt} (50%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1188d0e283..048e2f771d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -274,6 +274,8 @@ dependencies { implementation(projects.features.tangempay.onboarding.impl) implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.impl) + implementation(projects.features.yieldSupply.api) + implementation(projects.features.yieldSupply.api) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) 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 b22c123693..6a91513972 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 @@ -52,7 +52,7 @@ "version": "5.28.0" }, { - "name": "YIELD_LENDING_FEATURE_ENABLED", + "name": "YIELD_SUPPLY_FEATURE_ENABLED", "version": "undefined" }, { diff --git a/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt b/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt deleted file mode 100644 index 3d1c8a10bd..0000000000 --- a/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.features.yieldlending.api - -interface YieldLendingFeatureToggles { - - val isYieldLendingFeatureEnabled: Boolean -} \ No newline at end of file diff --git a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt deleted file mode 100644 index 4e780b4e96..0000000000 --- a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.features.yieldlending.impl - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.yieldlending.api.YieldLendingFeatureToggles - -internal class DefaultYieldLendingFeatureToggles( - private val featureToggles: FeatureTogglesManager, -) : YieldLendingFeatureToggles { - override val isYieldLendingFeatureEnabled: Boolean - get() = featureToggles.isFeatureEnabled("YIELD_LENDING_FEATURE_ENABLED") -} \ No newline at end of file diff --git a/features/yield-lending/api/.gitignore b/features/yield-supply/api/.gitignore similarity index 100% rename from features/yield-lending/api/.gitignore rename to features/yield-supply/api/.gitignore diff --git a/features/yield-lending/api/build.gradle.kts b/features/yield-supply/api/build.gradle.kts similarity index 90% rename from features/yield-lending/api/build.gradle.kts rename to features/yield-supply/api/build.gradle.kts index da7d806bb3..7fbd6e9fd1 100644 --- a/features/yield-lending/api/build.gradle.kts +++ b/features/yield-supply/api/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "com.tangem.features.yieldlending.api" + namespace = "com.tangem.features.yield.supply.api" } dependencies { diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt new file mode 100644 index 0000000000..93edb89872 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.yield.supply.api + +interface YieldSupplyFeatureToggles { + + val isYieldSupplyFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/yield-lending/impl/.gitignore b/features/yield-supply/impl/.gitignore similarity index 100% rename from features/yield-lending/impl/.gitignore rename to features/yield-supply/impl/.gitignore diff --git a/features/yield-lending/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts similarity index 55% rename from features/yield-lending/impl/build.gradle.kts rename to features/yield-supply/impl/build.gradle.kts index 40ebde87d3..14dbb06d31 100644 --- a/features/yield-lending/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -8,24 +8,39 @@ plugins { } android { - namespace = "com.tangem.features.yieldlending.impl" + namespace = "com.tangem.features.yield.supply.impl" } dependencies { /** Feature */ - implementation(projects.features.yieldLending.api) + implementation(projects.features.yieldSupply.api) + implementation(projects.features.sendV2.api) /** Core */ implementation(projects.core.configToggles) implementation(projects.core.decompose) implementation(projects.core.ui) + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** SDK */ + implementation(tangemDeps.blockchain) { + exclude(module = "joda-time") + } + /** Domain */ implementation(projects.domain.models) - implementation(projects.domain.wallets.models) - implementation(projects.domain.tokens.models) implementation(projects.domain.appCurrency.models) + implementation(projects.domain.appCurrency) + implementation(projects.domain.wallets.models) + implementation(projects.domain.wallets) + implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) /** Compose */ implementation(deps.compose.foundation) @@ -35,6 +50,12 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.androidx.activity.compose) + /** Other */ + implementation(deps.decompose) + implementation(deps.decompose.ext.compose) + implementation(deps.timber) + implementation(deps.kotlin.immutable.collections) + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt new file mode 100644 index 0000000000..db476c617b --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.yield.supply.impl + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles + +internal class DefaultYieldSupplyFeatureToggles( + private val featureToggles: FeatureTogglesManager, +) : YieldSupplyFeatureToggles { + override val isYieldSupplyFeatureEnabled: Boolean + get() = featureToggles.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED") +} \ No newline at end of file diff --git a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt similarity index 50% rename from features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt rename to features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt index d666cb36a1..45b8806bd6 100644 --- a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt @@ -1,8 +1,8 @@ -package com.tangem.features.yieldlending.impl.di +package com.tangem.features.yield.supply.impl.di import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.yieldlending.api.YieldLendingFeatureToggles -import com.tangem.features.yieldlending.impl.DefaultYieldLendingFeatureToggles +import com.tangem.features.yield.supply.impl.DefaultYieldSupplyFeatureToggles +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -11,11 +11,11 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -internal object YieldLendingFeatureModule { +internal object YieldSupplyFeatureModule { @Singleton @Provides - fun provideYieldFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldLendingFeatureToggles { - return DefaultYieldLendingFeatureToggles(featureTogglesManager) + fun provideYieldFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldSupplyFeatureToggles { + return DefaultYieldSupplyFeatureToggles(featureTogglesManager) } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 99aee86df3..c70d2637a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -287,8 +287,8 @@ include(":features:account:impl") include(":features:token-recieve:api") include(":features:token-recieve:impl") -include(":features:yield-lending:api") -include(":features:yield-lending:impl") +include(":features:yield-supply:api") +include(":features:yield-supply:impl") // endregion Feature modules // region Domain modules From 7135ce83fa1f939d813c60cc35858f29b47c386b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 13:12:16 +0300 Subject: [PATCH 084/132] Updated on 2026-08-14 --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index efbc818ece..871b050803 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -29,7 +29,7 @@ platform :android do FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/dev/google-services.json", "../app") FileUtils.cp("../tangem-android-tools/CI/gradle_properties/tests_ci_gradle.properties", "../gradle.properties") puts File.read("../gradle.properties") - gradle(task: "testDebugUnitTest") + gradle(task: "testGoogleDebugUnitTest") end desc "Build internal APK Firebase App Distribution" From 772272285ad7652c4124f439545e7a66c72a97c3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Sep 2025 10:51:33 +0400 Subject: [PATCH 085/132] Updated on 2026-08-14 --- .../api/tangemTech/TangemTechApi.kt | 1 + .../data/account/di/AccountDataModule.kt | 16 ++++ .../fetcher/DefaultWalletAccountsFetcher.kt | 5 +- .../DefaultAccountsCRUDRepository.kt | 46 ++++------ .../DefaultAccountsCRUDRepositoryTest.kt | 91 ++++++++----------- 5 files changed, 79 insertions(+), 80 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index f98dfae648..d173c69c57 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -160,6 +160,7 @@ interface TangemTechApi { @GET("/v1/wallets/{walletId}/accounts/archived") suspend fun getWalletArchivedAccounts( @Path("walletId") walletId: String, + @Header("If-None-Match") eTag: String? = null, ): ApiResponse // endregion } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 61056db1c3..df7c1a3919 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -3,9 +3,13 @@ package com.tangem.data.account.di import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles +import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher import com.tangem.data.account.repository.DefaultAccountsCRUDRepository import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -31,18 +35,30 @@ internal object AccountDataModule { @Singleton fun provideAccountsCRUDRepository( tangemTechApi: TangemTechApi, + walletAccountsSaver: WalletAccountsSaver, accountsResponseStoreFactory: AccountsResponseStoreFactory, userWalletsStore: UserWalletsStore, + eTagsStore: ETagsStore, accountConverterFactoryContainer: AccountConverterFactoryContainer, dispatchers: CoroutineDispatcherProvider, ): AccountsCRUDRepository { return DefaultAccountsCRUDRepository( tangemTechApi = tangemTechApi, + walletAccountsSaver = walletAccountsSaver, accountsResponseStoreFactory = accountsResponseStoreFactory, archivedAccountsStoreFactory = ArchivedAccountsStoreFactory, userWalletsStore = userWalletsStore, + eTagsStore = eTagsStore, convertersContainer = accountConverterFactoryContainer, dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl + + @Provides + @Singleton + fun provideWalletAccountsSaver(impl: DefaultWalletAccountsFetcher): WalletAccountsSaver = impl } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index 45a16f947b..17bc75d8ff 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -21,6 +21,8 @@ import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton /** * Default implementation of [WalletAccountsFetcher] and [WalletAccountsSaver] @@ -34,7 +36,8 @@ import kotlinx.coroutines.withContext * [REDACTED_AUTHOR] */ -internal class DefaultWalletAccountsFetcher( +@Singleton +internal class DefaultWalletAccountsFetcher @Inject constructor( private val tangemTechApi: TangemTechApi, private val accountsResponseStoreFactory: AccountsResponseStoreFactory, private val userTokensSaver: UserTokensSaver, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index 73a4766449..80e69fc536 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -5,15 +5,17 @@ import arrow.core.raise.option import arrow.core.toOption import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.converter.ArchivedAccountConverter -import com.tangem.data.account.converter.SaveWalletAccountsResponseConverter import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStore import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository @@ -23,25 +25,23 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class DefaultAccountsCRUDRepository( private val tangemTechApi: TangemTechApi, + private val walletAccountsSaver: WalletAccountsSaver, private val accountsResponseStoreFactory: AccountsResponseStoreFactory, private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory, private val userWalletsStore: UserWalletsStore, + private val eTagsStore: ETagsStore, private val convertersContainer: AccountConverterFactoryContainer, private val dispatchers: CoroutineDispatcherProvider, ) : AccountsCRUDRepository { - private val saveAccountsMutex = Mutex() - override suspend fun getAccountListSync(userWalletId: UserWalletId): Option = option { val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) @@ -85,7 +85,10 @@ internal class DefaultAccountsCRUDRepository( override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) { val response = withContext(dispatchers.io) { - tangemTechApi.getWalletArchivedAccounts(walletId = userWalletId.stringValue).getOrThrow() + tangemTechApi.getWalletArchivedAccounts( + walletId = userWalletId.stringValue, + eTag = getETag(userWalletId), + ).getOrThrow() } val store = getArchivedAccountsStore(userWalletId = userWalletId) @@ -97,27 +100,12 @@ internal class DefaultAccountsCRUDRepository( } override suspend fun saveAccounts(accountList: AccountList) { - saveAccountsMutex.withLock { - val store = getAccountsResponseStore(userWalletId = accountList.userWallet.walletId) + val userWalletId = accountList.userWallet.walletId - store.data.firstOrNull()?.wallet?.version ?: 0 - val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet) + val accountsResponse = converter.convert(value = accountList) - withContext(dispatchers.io) { - tangemTechApi.saveWalletAccounts( - walletId = accountList.userWallet.walletId.stringValue, - eTag = "", // TODO("[REDACTED_JIRA]") - body = body, - ) - .getOrThrow() - } - - val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet) - - val accountsResponse = converter.convert(value = accountList) - - store.updateData { accountsResponse } - } + walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse) } override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option = option { @@ -132,9 +120,13 @@ internal class DefaultAccountsCRUDRepository( return userWalletsStore.getSyncStrict(userWalletId) } + private suspend fun getETag(userWalletId: UserWalletId): String? { + return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + } + private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? { val store = getAccountsResponseStore(userWalletId = userWalletId) - return store.data.firstOrNull() + return store.getSyncOrNull() } private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore { diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt index 6c620a5b8a..5f18006b72 100644 --- a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -9,8 +9,9 @@ import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStore import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse @@ -40,6 +41,7 @@ import kotlin.time.Duration.Companion.minutes class DefaultAccountsCRUDRepositoryTest { private val tangemTechApi: TangemTechApi = mockk() + private val walletAccountsSaver: WalletAccountsSaver = mockk(relaxUnitFun = true) private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() private val accountsResponseStore: AccountsResponseStore = mockk() @@ -50,6 +52,7 @@ class DefaultAccountsCRUDRepositoryTest { private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore) private val userWalletsStore: UserWalletsStore = mockk() + private val eTagsStore: ETagsStore = mockk() private val convertersContainer: AccountConverterFactoryContainer = mockk() private val accountListConverter: AccountListConverter = mockk() @@ -57,9 +60,11 @@ class DefaultAccountsCRUDRepositoryTest { private val repository = DefaultAccountsCRUDRepository( tangemTechApi = tangemTechApi, + walletAccountsSaver = walletAccountsSaver, accountsResponseStoreFactory = accountsResponseStoreFactory, archivedAccountsStoreFactory = archivedAccountsStoreFactory, userWalletsStore = userWalletsStore, + eTagsStore = eTagsStore, convertersContainer = convertersContainer, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -517,14 +522,17 @@ class DefaultAccountsCRUDRepositoryTest { totalTokens = 0, ) + val eTag = "etag123" val apiResponse = mockk { every { this@mockk.accounts } returns listOf(accountDTO) } val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO) + coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + coEvery { - tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } returns ApiResponse.Success(apiResponse) // Act @@ -535,15 +543,20 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(actual).containsExactly(archivedAccount) coVerifyOrder { - tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) + eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) archivedAccountsStoreFactory.create(userWalletId) } } @Test - fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { // Arrange + fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { + // Arrange + val eTag = "etag123" val exception = Exception("API error") - coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } throws exception + + coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } throws exception // Act val actual = runCatching { repository.fetchArchivedAccounts(userWalletId) }.exceptionOrNull()!! @@ -553,7 +566,10 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull() - coVerify { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } + coVerifyOrder { + eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) + } } } @@ -561,8 +577,6 @@ class DefaultAccountsCRUDRepositoryTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class SaveAccounts { - private val version = 1 - @Test fun `saveAccounts should call API and update store`() = runTest { // Arrange @@ -572,24 +586,9 @@ class DefaultAccountsCRUDRepositoryTest { val accountList = AccountList.empty(userWallet = userWallet) - val accountsResponse = mockk { - every { this@mockk.wallet.version } returns version - } - + val accountsResponse = mockk() accountsResponseStoreFlow.value = accountsResponse - val body = SaveWalletAccountsResponseConverter.convert(value = accountList) - - val apiResponse = ApiResponse.Success(Unit) - - coEvery { - tangemTechApi.saveWalletAccounts( - walletId = userWalletId.stringValue, - eTag = "", - body = body, - ) - } returns apiResponse - val converter = mockk { every { this@mockk.convert(accountList) } returns accountsResponse } @@ -598,8 +597,6 @@ class DefaultAccountsCRUDRepositoryTest { convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) } returns converter - coEvery { accountsResponseStore.updateData(transform = any()) } returns accountsResponse - // Act repository.saveAccounts(accountList) @@ -607,12 +604,9 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse) coVerifyOrder { - accountsResponseStoreFactory.create(userWalletId) - accountsResponseStore.data - tangemTechApi.saveWalletAccounts(userWalletId.stringValue, "", body) convertersContainer.getWalletAccountsResponseCF.create(userWallet) converter.convert(accountList) - accountsResponseStore.updateData(any()) + walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) } } @@ -625,39 +619,32 @@ class DefaultAccountsCRUDRepositoryTest { val accountList = AccountList.empty(userWallet = userWallet) - val accountsResponse = mockk { - every { this@mockk.wallet.version } returns version - } - + val accountsResponse = mockk() accountsResponseStoreFlow.value = accountsResponse - val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + val converter = mockk { + every { this@mockk.convert(accountList) } returns accountsResponse + } - val apiResponse = ApiResponse.Error(cause = ApiResponseError.NetworkException) as ApiResponse + every { + convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) + } returns converter - coEvery { - tangemTechApi.saveWalletAccounts( - walletId = userWalletId.stringValue, - eTag = "", - body = body, - ) - } returns apiResponse + val exception = Exception("Test error") + + coEvery { walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) } throws exception // Act val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!! // Assert - Truth.assertThat(actual).isEqualTo(ApiResponseError.NetworkException) + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) coVerifyOrder { - accountsResponseStoreFactory.create(userWalletId) - accountsResponseStore.data - tangemTechApi.saveWalletAccounts(userWalletId.stringValue, "", body) - } - - coVerify(inverse = true) { - convertersContainer.getWalletAccountsResponseCF.create(any()) - accountsResponseStore.updateData(any()) + convertersContainer.getWalletAccountsResponseCF.create(userWallet) + converter.convert(accountList) + walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) } } } From 6d25b648b71611cf1e5f0f4511a68b7629607579 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 12:52:30 +0500 Subject: [PATCH 086/132] Updated on 2026-08-14 --- .../CreateWalletSelectionModel.kt | 15 +++++++++++++++ .../entity/CreateWalletSelectionUM.kt | 1 + .../ui/CreateWalletSelectionContent.kt | 12 ++++++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 10e571261d..26ce10e204 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -70,6 +70,17 @@ internal class CreateWalletSelectionModel @Inject constructor( ), ) + init { + showAlreadyHaveWalletWithDelay() + } + + private fun showAlreadyHaveWalletWithDelay() { + modelScope.launch { + delay(SHOW_ALREADY_HAVE_WALLET_DELAY) + uiState.update { it.copy(showAlreadyHaveWallet = true) } + } + } + private fun onMobileWalletClick() { router.push(AppRoute.CreateMobileWallet) } @@ -181,4 +192,8 @@ internal class CreateWalletSelectionModel @Inject constructor( ), ) } + + companion object { + private const val SHOW_ALREADY_HAVE_WALLET_DELAY = 3000L + } } \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt index 38affd1226..ef14600b7a 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.createwalletselection.entity internal data class CreateWalletSelectionUM( val isScanInProgress: Boolean = false, val hardwareWalletPrice: String = "$54.90", + val showAlreadyHaveWallet: Boolean = false, val onBackClick: () -> Unit, val onMobileWalletClick: () -> Unit, val onHardwareWalletClick: () -> Unit, diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt index abae1d96ad..cc4e15fb2a 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt @@ -1,6 +1,7 @@ package com.tangem.features.createwalletselection.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -128,10 +129,12 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi onClick = state.onHardwareWalletClick, ) } - AlreadyHaveTangemWalletBlock( - onScanClick = state.onScanClick, - isScanInProgress = state.isScanInProgress, - ) + AnimatedVisibility(state.showAlreadyHaveWallet) { + AlreadyHaveTangemWalletBlock( + onScanClick = state.onScanClick, + isScanInProgress = state.isScanInProgress, + ) + } } } @@ -238,6 +241,7 @@ private fun PreviewCreateWalletContent() { TangemThemePreview { CreateWalletSelectionContent( state = CreateWalletSelectionUM( + showAlreadyHaveWallet = true, onBackClick = {}, onMobileWalletClick = {}, onHardwareWalletClick = {}, From 61f2412cb56138063da20b431d0cb4f01de6190d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 12:52:38 +0500 Subject: [PATCH 087/132] Updated on 2026-08-14 --- .../start/AddExistingWalletStartModel.kt | 16 ++++++++++++++++ .../start/entity/AddExistingWalletStartUM.kt | 1 + .../start/ui/AddExistingWalletStartContent.kt | 10 +++++++--- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt index 9f56cce80b..e39dd4adbb 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt @@ -65,6 +65,7 @@ internal class AddExistingWalletStartModel @Inject constructor( internal val uiState: StateFlow field = MutableStateFlow( AddExistingWalletStartUM( + showWantToPurchaseBlock = false, isScanInProgress = false, onBackClick = params.callbacks::onBackClick, onImportPhraseClick = params.callbacks::onImportPhraseClick, @@ -73,6 +74,17 @@ internal class AddExistingWalletStartModel @Inject constructor( ), ) + init { + showWantToPurchaseBlockWithDelay() + } + + private fun showWantToPurchaseBlockWithDelay() { + modelScope.launch { + delay(SHOW_WANT_TO_PURCHASE_BLOCK_DELAY) + uiState.update { it.copy(showWantToPurchaseBlock = true) } + } + } + private fun onShopClick() { analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards) analyticsEventHandler.send(Shop.ScreenOpened) @@ -180,4 +192,8 @@ internal class AddExistingWalletStartModel @Inject constructor( ), ) } + + companion object { + private const val SHOW_WANT_TO_PURCHASE_BLOCK_DELAY = 3000L + } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt index 37a5113f35..c448d2c471 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/entity/AddExistingWalletStartUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.hotwallet.addexistingwallet.start.entity internal data class AddExistingWalletStartUM( + val showWantToPurchaseBlock: Boolean, val isScanInProgress: Boolean, val onBackClick: () -> Unit, val onImportPhraseClick: () -> Unit, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt index 184f5f924f..42588c81de 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt @@ -1,6 +1,7 @@ package com.tangem.features.hotwallet.addexistingwallet.start.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator @@ -127,9 +128,11 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = false, ) } - BuyTangemWalletBlock( - onScanClick = state.onBuyCardClick, - ) + AnimatedVisibility(state.showWantToPurchaseBlock) { + BuyTangemWalletBlock( + onScanClick = state.onBuyCardClick, + ) + } } } @@ -176,6 +179,7 @@ private fun PreviewCreateWalletContent() { TangemThemePreview { AddExistingWalletStartContent( state = AddExistingWalletStartUM( + showWantToPurchaseBlock = true, isScanInProgress = true, onBackClick = {}, onImportPhraseClick = {}, From e3ec61bc4b6e03e8b151929519702ef779cbb013 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 16:16:44 +0400 Subject: [PATCH 088/132] Updated on 2026-08-14 --- .../tap/data/RuntimeUserWalletsStore.kt | 3 + .../data/UserWalletsStoreRepositoryProxy.kt | 5 +- .../tap/di/domain/AccountDomainModule.kt | 13 + .../local/userwallet/UserWalletsStore.kt | 2 + .../DefaultAccountsCRUDRepository.kt | 12 +- .../DefaultAccountsCRUDRepositoryTest.kt | 90 ++++++ .../repository/AccountsCRUDRepository.kt | 15 +- .../GetUnoccupiedAccountIndexUseCase.kt | 2 +- .../usecase/IsAccountsModeEnabledUseCase.kt | 66 ++++ .../GetUnoccupiedAccountIndexUseCaseTest.kt | 8 +- .../IsAccountsModeEnabledUseCaseTest.kt | 301 ++++++++++++++++++ 11 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt create mode 100644 domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt b/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt index d780f3d15a..d0fd68250c 100644 --- a/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt +++ b/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt @@ -19,6 +19,9 @@ internal class RuntimeUserWalletsStore( override val userWallets: Flow> get() = userWalletsListManager.userWallets + override val userWalletsSync: List + get() = userWalletsListManager.userWalletsSync + override fun getSyncOrNull(key: UserWalletId): UserWallet? { return userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == key } } diff --git a/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt b/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt index c5275bf034..d41ea29466 100644 --- a/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt +++ b/app/src/main/java/com/tangem/tap/data/UserWalletsStoreRepositoryProxy.kt @@ -3,9 +3,9 @@ package com.tangem.tap.data import com.tangem.common.CompletionResult import com.tangem.common.catching import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.core.wallets.UserWalletsListRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -24,6 +24,9 @@ class UserWalletsStoreRepositoryProxy( } } + override val userWalletsSync: List + get() = userWalletsListRepository.userWallets.value.orEmpty() + override fun getSyncOrNull(key: UserWalletId): UserWallet? { return userWalletsListRepository.userWallets.value?.find { it.walletId == key } } diff --git a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt index 8cf8edf8d6..c4036dd181 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.domain +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.usecase.* import dagger.Module @@ -49,4 +50,16 @@ internal object AccountDomainModule { ): GetUnoccupiedAccountIndexUseCase { return GetUnoccupiedAccountIndexUseCase(crudRepository = accountsCRUDRepository) } + + @Provides + @Singleton + fun provideIsAccountsModeEnabledUseCase( + accountsCRUDRepository: AccountsCRUDRepository, + accountsFeatureToggles: AccountsFeatureToggles, + ): IsAccountsModeEnabledUseCase { + return IsAccountsModeEnabledUseCase( + crudRepository = accountsCRUDRepository, + accountsFeatureToggles = accountsFeatureToggles, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt index b4d50f01de..8f00255449 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt @@ -15,6 +15,8 @@ interface UserWalletsStore { val userWallets: Flow> + val userWalletsSync: List + fun getSyncOrNull(key: UserWalletId): UserWallet? fun getSyncStrict(key: UserWalletId): UserWallet diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index 80e69fc536..a9ceb3026e 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -25,6 +25,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext /** @@ -108,7 +109,7 @@ internal class DefaultAccountsCRUDRepository( walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse) } - override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option = option { + override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option = option { val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) ensureNotNull(accountListResponse) @@ -116,10 +117,19 @@ internal class DefaultAccountsCRUDRepository( return accountListResponse.wallet.totalAccounts.toOption() } + override fun getTotalAccountsCount(userWalletId: UserWalletId): Flow> { + return getAccountsResponseStore(userWalletId = userWalletId).data + .map { it?.wallet?.totalAccounts.toOption() } + } + override fun getUserWallet(userWalletId: UserWalletId): UserWallet { return userWalletsStore.getSyncStrict(userWalletId) } + override fun getUserWallets(): Flow> = userWalletsStore.userWallets + + override fun getUserWalletsSync(): List = userWalletsStore.userWalletsSync + private suspend fun getETag(userWalletId: UserWalletId): String? { return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) } diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt index 5f18006b72..1e7c97fc64 100644 --- a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -648,4 +648,94 @@ class DefaultAccountsCRUDRepositoryTest { } } } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetTotalAccountsCountSync { + + @Test + fun `getTotalAccountsCountSync returns None if account list response is null`() = runTest { + // Arrange + accountsResponseStoreFlow.value = null + + // Act + val actual = repository.getTotalAccountsCountSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + + @Test + fun `getTotalAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest { + // Arrange + val totalAccounts = 5 + val response = mockk { + every { this@mockk.wallet.totalAccounts } returns totalAccounts + } + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getTotalAccountsCountSync(userWalletId) + + // Assert + val expected = totalAccounts.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetTotalAccountsCount { + + @Test + fun `getTotalAccountsCount emits 0 when account list response is null`() = runTest { + // Arrange + accountsResponseStoreFlow.value = null + + // Act + val flow = repository.getTotalAccountsCount(userWalletId) + val actual = getEmittedValues(flow) + + // Assert + Truth.assertThat(actual).containsExactly(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + + @Test + fun `getTotalAccountsCount emits correct value when response is valid`() = runTest { + // Arrange + val totalAccounts = 7 + val response = mockk { + every { this@mockk.wallet.totalAccounts } returns totalAccounts + } + + accountsResponseStoreFlow.value = response + + // Act + val flow = repository.getTotalAccountsCount(userWalletId) + val actual = getEmittedValues(flow) + + // Assert + Truth.assertThat(actual).containsExactly(totalAccounts.toOption()) + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + } + } } \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt index 8904840bcb..e58992d600 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt @@ -73,7 +73,14 @@ interface AccountsCRUDRepository { * * @param userWalletId the unique identifier of the user wallet */ - suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option + suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option + + /** + * Provides a flow of the total count of accounts associated with a specific user wallet including archived accounts + * + * @param userWalletId the unique identifier of the user wallet + */ + fun getTotalAccountsCount(userWalletId: UserWalletId): Flow> /** * Retrieves a user wallet by its unique identifier @@ -82,4 +89,10 @@ interface AccountsCRUDRepository { * @return the [UserWallet] associated with the given identifier */ fun getUserWallet(userWalletId: UserWalletId): UserWallet + + /** Provides a flow of all user wallets */ + fun getUserWallets(): Flow> + + /** Synchronously retrieves all user wallets */ + fun getUserWalletsSync(): List } \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt index f511ab03b8..9fbb2ed7d5 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt @@ -35,7 +35,7 @@ class GetUnoccupiedAccountIndexUseCase( private suspend fun Raise.getTotalAccountsCount(userWalletId: UserWalletId): Int { return catch( - block = { crudRepository.getTotalAccountsCount(userWalletId = userWalletId) }, + block = { crudRepository.getTotalAccountsCountSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.DataNotFound) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt new file mode 100644 index 0000000000..9c295b058d --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.account.usecase + +import arrow.core.Option +import arrow.core.getOrElse +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +/** + * Use case to determine if the accounts mode is enabled. + * Accounts mode is considered enabled if there are at least two accounts in any of the user wallets that support + * multiple currencies. + * + * @property crudRepository repository to interact with user wallets and their accounts + * +[REDACTED_AUTHOR] + */ +class IsAccountsModeEnabledUseCase( + private val crudRepository: AccountsCRUDRepository, + private val accountsFeatureToggles: AccountsFeatureToggles, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(): Flow { + if (!accountsFeatureToggles.isFeatureEnabled) return flowOf(value = false) + + return crudRepository.getUserWallets() + .flatMapLatest { userWallets -> + val totalAccountsCountList = getTotalAccountsCountList(userWallets) + + combine(flows = totalAccountsCountList) { it.toList().isModeEnabled() } + } + .onEmpty { emit(false) } + } + + suspend fun invokeSync(): Boolean { + if (!accountsFeatureToggles.isFeatureEnabled) return false + + return crudRepository.getUserWalletsSync() + .map { userWallet -> + // If the wallet does not support multiple currencies, we consider its account count as 0 + if (!userWallet.isMultiCurrency) return@map 0 + + crudRepository.getTotalAccountsCountSync(userWalletId = userWallet.walletId).getOrZero() + } + .isModeEnabled() + } + + private fun getTotalAccountsCountList(userWallets: List): List> { + return userWallets + .map { userWallet -> + // If the wallet does not support multiple currencies, we consider its account count as 0 + if (!userWallet.isMultiCurrency) return@map flowOf(0) + + crudRepository.getTotalAccountsCount(userWalletId = userWallet.walletId) + .map { maybeCount -> maybeCount.getOrZero() } + } + } + + private fun Option.getOrZero(): Int = getOrElse { 0 } + + private fun List.isModeEnabled(): Boolean = any { it >= 2 } +} \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt index a0ba98b709..5f85fe95ef 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt @@ -30,7 +30,7 @@ class GetUnoccupiedAccountIndexUseCaseTest { @Test fun `invoke should return next unoccupied index when repository returns count`() = runTest { // Arrange - coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3.toOption() + coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } returns 3.toOption() // Act val actual = useCase(userWalletId = userWalletId) @@ -39,14 +39,14 @@ class GetUnoccupiedAccountIndexUseCaseTest { val expected = DerivationIndex(4) Truth.assertThat(actual).isEqualTo(expected) - coVerify { crudRepository.getTotalAccountsCount(userWalletId) } + coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) } } @Test fun `invoke should return error if repository throws exception`() = runTest { // Arrange val exception = IllegalStateException("Test error") - coEvery { crudRepository.getTotalAccountsCount(userWalletId) } throws exception + coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } throws exception // Act val actual = useCase(userWalletId = userWalletId) @@ -55,6 +55,6 @@ class GetUnoccupiedAccountIndexUseCaseTest { val expected = GetUnoccupiedAccountIndexUseCase.Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerify { crudRepository.getTotalAccountsCount(userWalletId) } + coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) } } } \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt new file mode 100644 index 0000000000..06b7c267d0 --- /dev/null +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -0,0 +1,301 @@ +package com.tangem.domain.account.usecase + +import arrow.core.none +import arrow.core.some +import com.google.common.truth.Truth +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import io.mockk.* +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class IsAccountsModeEnabledUseCaseTest { + + private val accountsCRUDRepository: AccountsCRUDRepository = mockk() + private val featureToggles: AccountsFeatureToggles = mockk() + + private val useCase = IsAccountsModeEnabledUseCase(accountsCRUDRepository, featureToggles) + + @AfterEach + fun tearDown() { + clearMocks(accountsCRUDRepository, featureToggles) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Invoke { + + @Test + fun `returns false when feature is disabled`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns false + + // Act + val actual = useCase.invoke().firstOrNull() + + // Assert + Truth.assertThat(actual).isFalse() + + verify(exactly = 1) { featureToggles.isFeatureEnabled } + verify(inverse = true) { accountsCRUDRepository.getUserWallets() } + } + + @Test + fun `returns false when getUserWallets emits empty flow`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns emptyFlow() + + // Act + val actual = useCase.invoke().firstOrNull() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + } + + verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + } + + @Test + fun `returns false when getUserWallets emits one wallet with isMultiCurrency false`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = false) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + } + + verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + } + + @Test + fun `returns true when getUserWallets emits one wallet with isMultiCurrency true`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) + every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(2.some()) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isTrue() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + } + } + + @Test + fun `returns false when getUserWallets emits one wallet with isMultiCurrency true and None counts`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) + every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(none()) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + } + } + + @Test + fun `returns true when getUserWallets emits two wallets, one isMultiCurrency false, one true`() = runTest { + // Arrange + val wallet1 = createUserWallet(isMultiCurrency = false) + val wallet2 = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet1, wallet2)) + every { accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) } returns flowOf(2.some()) + + // Act + val actual = useCase.invoke().first() + + // Assert + Truth.assertThat(actual).isTrue() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWallets() + accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) + } + + verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(wallet1.walletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class InvokeSync { + + @Test + fun `returns false when feature is disabled`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns false + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + verify(exactly = 1) { featureToggles.isFeatureEnabled } + verify(inverse = true) { accountsCRUDRepository.getUserWalletsSync() } + } + + @Test + fun `returns false when getUserWalletsSync returns empty list`() = runTest { + // Arrange + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns emptyList() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + } + + coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + } + + @Test + fun `returns false when getUserWalletsSync returns one wallet with isMultiCurrency false`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = false) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + verifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + } + + coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + } + + @Test + fun `returns true when getUserWalletsSync returns one wallet with isMultiCurrency true`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) + coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns 2.some() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isTrue() + + coVerifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + } + } + + @Test + fun `returns false when getUserWalletsSync returns multi wallet with None counts`() = runTest { + // Arrange + val wallet = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) + coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns none() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isFalse() + + coVerifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + } + } + + @Test + fun `returns true when getUserWalletsSync returns multi and single wallets`() = runTest { + // Arrange + val wallet1 = createUserWallet(isMultiCurrency = false) + val wallet2 = createUserWallet(isMultiCurrency = true) + + every { featureToggles.isFeatureEnabled } returns true + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet1, wallet2) + coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) } returns 2.some() + + // Act + val actual = useCase.invokeSync() + + // Assert + Truth.assertThat(actual).isTrue() + + coVerifyOrder { + featureToggles.isFeatureEnabled + accountsCRUDRepository.getUserWalletsSync() + accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) + } + + coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(wallet1.walletId) } + } + } + + private fun createUserWallet(isMultiCurrency: Boolean): UserWallet = mockk { + every { this@mockk.walletId } returns UserWalletId(stringValue = "011") + every { this@mockk.isMultiCurrency } returns isMultiCurrency + } +} \ No newline at end of file From c3bfe301a560f0962af565af534e0971dfbfd079 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 14:01:41 +0500 Subject: [PATCH 089/132] Updated on 2026-08-14 --- .../network/MockNetworkStatusFactory.kt | 1 + .../networks/utils/NetworkStatusFactory.kt | 30 +++++++++++++ .../UpdateWalletManagerResultFactory.kt | 39 +++++++++++----- .../models/currency/CryptoCurrencyStatus.kt | 10 +++++ .../domain/models/network/NetworkStatus.kt | 2 + .../models/yield/supply/YieldSupplyStatus.kt | 20 +++++++++ .../operations/CurrencyStatusOperations.kt | 4 ++ .../transformers/SetVisaInfoTransformer.kt | 1 + gradle/tangem_dependencies.toml | 4 +- .../models/UpdateWalletManagerResult.kt | 45 ++++++++++++++----- .../tangem/blockchainsdk/utils/NetworkExt.kt | 1 + 11 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/yield/supply/YieldSupplyStatus.kt diff --git a/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt index 34fac4a101..99a612b847 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt @@ -30,6 +30,7 @@ object MockNetworkStatusFactory { ), amounts = mapOf(), pendingTransactions = mapOf(), + yieldSupplyStatuses = mapOf(), source = source, ) .let(transform), diff --git a/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt b/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt index 018a340fd4..9c9742678c 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/utils/NetworkStatusFactory.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import timber.log.Timber /** Factory for creating [NetworkStatus] */ @@ -70,6 +71,10 @@ object NetworkStatusFactory { transactions = result.currentTransactions, currencies = addedCurrencies, ), + yieldSupplyStatuses = formatYieldSupplyStatuses( + amounts = result.currenciesAmounts, + currencies = addedCurrencies, + ), source = StatusSource.ACTUAL, ) } @@ -107,6 +112,31 @@ object NetworkStatusFactory { } } + private fun formatYieldSupplyStatuses( + amounts: Set, + currencies: Set, + ): Map { + return currencies.associate { currency -> + val amount = when (currency) { + is CryptoCurrency.Coin -> null + is CryptoCurrency.Token -> { + amounts.filterIsInstance() + .firstOrNull { amount -> + currency.id.rawCurrencyId == amount.currencyRawId && + currency.contractAddress.equals(amount.contractAddress, ignoreCase = true) + } + } + } + + if (amount == null) { + Timber.w("Unable to find amount for cryptocurrency: $currency") + currency.id to null + } else { + currency.id to amount.yieldSupplyStatus + } + } + } + private fun formatTransactions( transactions: Set, currencies: Set, diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt index 0d7e463bb3..55054ba3c8 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactory.kt @@ -1,24 +1,20 @@ package com.tangem.data.walletmanager -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.Token -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionStatus -import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.* import com.tangem.blockchain.common.address.Address import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.blockchainsdk.utils.amountToCreateAccount import com.tangem.data.walletmanager.utils.SdkAddressToAddressConverter import com.tangem.data.walletmanager.utils.TransactionDataToTxHistoryItemConverter import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import timber.log.Timber import java.math.BigDecimal -/** Factory for creating [com.tangem.blockchainsdk.models.UpdateWalletManagerResult] */ +/** Factory for creating [UpdateWalletManagerResult] */ internal class UpdateWalletManagerResultFactory { - /** Get [com.tangem.blockchainsdk.models.UpdateWalletManagerResult.Verified] result for [walletManager] */ + /** Get [UpdateWalletManagerResult.Verified] result for [walletManager] */ fun getResult(walletManager: WalletManager): UpdateWalletManagerResult.Verified { val wallet = walletManager.wallet val addresses = getAvailableAddresses(wallet.addresses) @@ -110,7 +106,7 @@ internal class UpdateWalletManagerResultFactory { is AmountType.Token -> { val value = getCurrencyAmountValue(amount) ?: return null - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( currencyRawId = type.token.id?.let(CryptoCurrency::RawID), contractAddress = type.token.contractAddress, value = value, @@ -121,6 +117,20 @@ internal class UpdateWalletManagerResultFactory { UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = value) } + is AmountType.TokenYieldSupply -> { + val value = getCurrencyAmountValue(amount) ?: return null + + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.YieldSupplyToken( + value = value, + currencyRawId = type.token.id?.let(CryptoCurrency::RawID), + contractAddress = type.token.contractAddress, + yieldSupplyStatus = YieldSupplyStatus( + isActive = type.isActive, + isInitialized = type.isInitialized, + isAllowedToSpend = type.isAllowedToSpend, + ), + ) + } is AmountType.FeeResource, is AmountType.Reserve, -> null @@ -147,7 +157,7 @@ internal class UpdateWalletManagerResultFactory { ) return tokens.mapTo(demoAmounts) { token -> - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( currencyRawId = token.id?.let(CryptoCurrency::RawID), contractAddress = token.contractAddress, value = amountValue, @@ -188,6 +198,15 @@ internal class UpdateWalletManagerResultFactory { txInfo = txHistoryItem, ) } + is AmountType.TokenYieldSupply -> { + val txHistoryItem = txHistoryItemConverter.convert(data) ?: return null + + UpdateWalletManagerResult.CryptoCurrencyTransaction.Token( + tokenId = type.token.id, + contractAddress = type.token.contractAddress, + txInfo = txHistoryItem, + ) + } is AmountType.FeeResource, is AmountType.Reserve, -> null diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt index 64e478276e..0663a46f9f 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyStatus.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.staking.YieldBalance +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import kotlinx.serialization.Serializable /** @@ -58,6 +59,12 @@ data class CryptoCurrencyStatus( /** Staking yield balance */ val yieldBalance: YieldBalance? get() = null + /** + * !!! DO NOT CONFUSE with STAKING YIELD BALANCE + * Yield supply status + */ + val yieldSupplyStatus: YieldSupplyStatus? get() = null + /** Sources */ val sources: Sources get() = Sources() } @@ -157,6 +164,7 @@ data class CryptoCurrencyStatus( override val fiatRate: SerializedBigDecimal, override val priceChange: SerializedBigDecimal, override val yieldBalance: YieldBalance?, + override val yieldSupplyStatus: YieldSupplyStatus?, override val hasCurrentNetworkTransactions: Boolean, override val pendingTransactions: Set, override val networkAddress: NetworkAddress, @@ -184,6 +192,7 @@ data class CryptoCurrencyStatus( override val fiatRate: SerializedBigDecimal?, override val priceChange: SerializedBigDecimal?, override val yieldBalance: YieldBalance?, + override val yieldSupplyStatus: YieldSupplyStatus?, override val hasCurrentNetworkTransactions: Boolean, override val pendingTransactions: Set, override val networkAddress: NetworkAddress, @@ -205,6 +214,7 @@ data class CryptoCurrencyStatus( data class NoQuote( override val amount: SerializedBigDecimal, override val yieldBalance: YieldBalance?, + override val yieldSupplyStatus: YieldSupplyStatus?, override val hasCurrentNetworkTransactions: Boolean, override val pendingTransactions: Set, override val networkAddress: NetworkAddress, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt index 9911c172f3..d5f1834bf3 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/NetworkStatus.kt @@ -2,6 +2,7 @@ package com.tangem.domain.models.network import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import java.math.BigDecimal /** @@ -58,6 +59,7 @@ data class NetworkStatus(val network: Network, val value: Value) { val address: NetworkAddress, val amounts: Map, val pendingTransactions: Map>, + val yieldSupplyStatuses: Map, override val source: StatusSource, ) : Value() diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/yield/supply/YieldSupplyStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/yield/supply/YieldSupplyStatus.kt new file mode 100644 index 0000000000..3a8165a366 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/yield/supply/YieldSupplyStatus.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.models.yield.supply + +import kotlinx.serialization.Serializable + +/** + * Represents the status of yield supply for a cryptocurrency asset. + * + * This data class encapsulates the current state of yield supply, including whether it is active, + * initialized, and allowed to spend. + * + * @property isActive Indicates if the yield token is currently active. + * @property isInitialized Indicates if the yield token has been initialized. + * @property isAllowedToSpend Indicates if spending from the yield module is permitted. + */ +@Serializable +data class YieldSupplyStatus( + val isActive: Boolean, + val isInitialized: Boolean, + val isAllowedToSpend: Boolean, +) \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt index c1dc1ffb33..5a6576e4b3 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrencyStatusOperations.kt @@ -94,6 +94,7 @@ internal class CurrencyStatusOperations( } else { null } + val yieldSupplyStatus = networkStatusValue.yieldSupplyStatuses[currency.id] val quoteValue = quoteStatus?.value @@ -108,6 +109,7 @@ internal class CurrencyStatusOperations( pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, + yieldSupplyStatus = yieldSupplyStatus, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, quoteSource = quoteStatus?.value?.source ?: StatusSource.ACTUAL, @@ -120,6 +122,7 @@ internal class CurrencyStatusOperations( pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, + yieldSupplyStatus = yieldSupplyStatus, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, quoteSource = quoteStatus?.value?.source ?: StatusSource.ACTUAL, @@ -135,6 +138,7 @@ internal class CurrencyStatusOperations( pendingTransactions = currentTransactions, networkAddress = networkStatusValue.address, yieldBalance = currentYieldBalance, + yieldSupplyStatus = yieldSupplyStatus, sources = CryptoCurrencyStatus.Sources( networkSource = networkStatusValue.source, quoteSource = quoteValue.source, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt index 2accc6f5b7..751185ccf4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt @@ -135,6 +135,7 @@ internal class SetVisaInfoTransformer( fiatRate = visaCurrency.fiatRate, priceChange = visaCurrency.priceChange, yieldBalance = null, + yieldSupplyStatus = null, hasCurrentNetworkTransactions = false, pendingTransactions = emptySet(), networkAddress = visaCurrency.paymentAccountAddress, diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 25b1732ddf..8b6c15d617 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,8 +5,8 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1222" -#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds +#tangemBlockchainSdk = "develop-1222" +tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt index 200f9c2d8b..7064211d88 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/models/UpdateWalletManagerResult.kt @@ -2,6 +2,7 @@ package com.tangem.blockchainsdk.models import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import java.math.BigDecimal /** Result of updating wallet manager */ @@ -65,18 +66,38 @@ sealed class UpdateWalletManagerResult { */ data class Coin(override val value: BigDecimal) : CryptoCurrencyAmount - /** - * Token - * - * @property currencyRawId crypto currency id - * @property contractAddress token contract address - * @property value amount value - */ - data class Token( - override val value: BigDecimal, - val currencyRawId: CryptoCurrency.RawID?, - val contractAddress: String, - ) : CryptoCurrencyAmount + sealed interface Token : CryptoCurrencyAmount { + val currencyRawId: CryptoCurrency.RawID? + val contractAddress: String + + /** + * Basic Token + * + * @property currencyRawId crypto currency id + * @property contractAddress token contract address + * @property value amount value + */ + data class BasicToken( + override val value: BigDecimal, + override val currencyRawId: CryptoCurrency.RawID?, + override val contractAddress: String, + ) : Token + + /** + * Yield Supply Token + * + * @property currencyRawId crypto currency id + * @property contractAddress token contract address + * @property value amount value + * @property yieldSupplyStatus status of the yield token + */ + data class YieldSupplyToken( + override val value: BigDecimal, + override val currencyRawId: CryptoCurrency.RawID?, + override val contractAddress: String, + val yieldSupplyStatus: YieldSupplyStatus, + ) : Token + } } /** Crypto currency transaction */ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt index e6534bcb53..203d2fddbd 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt @@ -9,4 +9,5 @@ fun Network.toBlockchain(): Blockchain = id.toBlockchain() /** Converts [Network.ID] to [Blockchain] */ fun Network.ID.toBlockchain(): Blockchain = rawId.toBlockchain() +/** Converts [Network.RawID] to [Blockchain] */ fun Network.RawID.toBlockchain(): Blockchain = Blockchain.fromId(id = value) \ No newline at end of file From b88de49ba3a7c562bb74958161d43202baf444a3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 14:01:56 +0500 Subject: [PATCH 090/132] Updated on 2026-08-14 --- .../com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt | 6 +++++- .../tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 8a8f3e30d3..0e7eda2f34 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -7,6 +7,7 @@ import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.datastorage.BlockchainDataStorage import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.blockchainsdk.providers.BlockchainProviderTypes +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import timber.log.Timber import javax.inject.Inject @@ -23,6 +24,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( private val accountCreator: AccountCreator, private val blockchainDataStorage: BlockchainDataStorage, private val blockchainSDKLogger: BlockchainSDKLogger, + private val featureTogglesManager: FeatureTogglesManager, ) { fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory { @@ -32,7 +34,9 @@ internal class WalletManagerFactoryCreator @Inject constructor( config = config, blockchainProviderTypes = blockchainProviderTypes, accountCreator = accountCreator, - featureToggles = BlockchainFeatureToggles(), + featureToggles = BlockchainFeatureToggles( + isYieldSupplyEnabled = featureTogglesManager.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED"), + ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), ) diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt index cce9a8c942..9abd481655 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt @@ -17,6 +17,7 @@ import com.tangem.blockchainsdk.providers.BlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.DevBlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.ProdBlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.dev.BlockchainProvidersResponseSerializer +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage @@ -90,11 +91,13 @@ internal object BlockchainSDKFactoryModule { tangemTechApi: TangemTechApi, appPreferencesStore: AppPreferencesStore, blockchainSDKLogger: BlockchainSDKLogger, + featureTogglesManager: FeatureTogglesManager, ): WalletManagerFactoryCreator { return WalletManagerFactoryCreator( accountCreator = DefaultAccountCreator(tangemTechApi), blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore), blockchainSDKLogger = blockchainSDKLogger, + featureTogglesManager = featureTogglesManager, ) } } \ No newline at end of file From 664e71b782c03238cb8e377497cf3dec8d521d6d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 14:02:21 +0500 Subject: [PATCH 091/132] Updated on 2026-08-14 --- .../MockUpdateWalletManagerResultFactory.kt | 37 +++++++++ .../local/network/entity/NetworkStatusDM.kt | 10 ++- .../NetworkStatusDMSerializationTest.kt | 24 +++++- .../NetworkStatusDataModelConverter.kt | 1 + .../NetworkYieldSupplyStatusConverter.kt | 46 ++++++++++ .../SimpleNetworkStatusConverter.kt | 1 + .../NetworkStatusDataModelConverterTest.kt | 24 ++++++ .../NetworkYieldSupplyStatusConverterTest.kt | 83 +++++++++++++++++++ .../SimpleNetworkStatusConverterTest.kt | 28 +++++++ .../fetcher/CommonNetworkStatusFetcherTest.kt | 5 ++ .../utils/NetworkStatusFactoryTest.kt | 52 ++++++++++++ 11 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt create mode 100644 data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt diff --git a/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt index 482cb7c1f6..7abe7f7217 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt @@ -2,7 +2,9 @@ package com.tangem.common.test.domain.walletmanager import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.blockchainsdk.models.UpdateWalletManagerResult.* +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import java.math.BigDecimal /** @@ -41,6 +43,41 @@ class MockUpdateWalletManagerResultFactory { ) } + fun createVerifiedWithToken(): Verified { + return Verified( + selectedAddress = "0x1", + addresses = setOf(Address(value = "0x1", type = Address.Type.Primary)), + currenciesAmounts = setOf( + CryptoCurrencyAmount.Token.BasicToken( + value = BigDecimal.ONE, + currencyRawId = CryptoCurrency.RawID("token"), + contractAddress = "0xTokenAddress", + ), + ), + currentTransactions = setOf(CryptoCurrencyTransaction.Coin(txInfo)), + ) + } + + fun createVerifiedWithSuppliedToken(): Verified { + return Verified( + selectedAddress = "0x1", + addresses = setOf(Address(value = "0x1", type = Address.Type.Primary)), + currenciesAmounts = setOf( + CryptoCurrencyAmount.Token.YieldSupplyToken( + value = BigDecimal.ONE, + currencyRawId = CryptoCurrency.RawID("token"), + contractAddress = "0xTokenAddress", + yieldSupplyStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = false, + ), + ), + ), + currentTransactions = setOf(CryptoCurrencyTransaction.Coin(txInfo)), + ) + } + private companion object { val txInfo = TxInfo( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt index 280c5f4040..48a30365ee 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt @@ -2,8 +2,6 @@ package com.tangem.datasource.local.network.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import com.tangem.datasource.local.network.entity.NetworkStatusDM.NoAccount -import com.tangem.datasource.local.network.entity.NetworkStatusDM.Verified import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel import java.math.BigDecimal @@ -44,6 +42,7 @@ sealed interface NetworkStatusDM { @Json(name = "selected_address") override val selectedAddress: String, @Json(name = "available_addresses") override val availableAddresses: Set

    , @Json(name = "amounts") val amounts: Map, + @Json(name = "yield_supply_statuses") val yieldSupplyStatuses: Map = emptyMap(), ) : NetworkStatusDM /** @@ -107,4 +106,11 @@ sealed interface NetworkStatusDM { Secondary, } } + + @JsonClass(generateAdapter = true) + data class YieldSupplyStatus( + @Json(name = "is_active") val isActive: Boolean, + @Json(name = "is_initialized") val isInitialized: Boolean, + @Json(name = "is_allowed_to_spend") val isAllowedToSpend: Boolean, + ) } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt index a8bae9462e..4bc735b0dc 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt @@ -42,7 +42,10 @@ class NetworkStatusDMSerializationTest { { "value": "0x123456", "type": "primary" }, { "value": "0xabcdef", "type": "secondary" } ], - "amounts": { "ETH": "1.2345" } + "amounts": { "ETH": "1.2345" }, + "yield_supply_statuses": { + "ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false } + } } """.trimIndent() @@ -62,6 +65,13 @@ class NetworkStatusDMSerializationTest { NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary), ), amounts = mapOf("ETH" to BigDecimal("1.2345")), + yieldSupplyStatuses = mapOf( + "ETH" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ) Truth.assertThat(result).isEqualTo(expected) @@ -82,6 +92,13 @@ class NetworkStatusDMSerializationTest { NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary), ), amounts = mapOf("ETH" to BigDecimal("1.2345")), + yieldSupplyStatuses = mapOf( + "ETH" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ) // Act @@ -100,7 +117,10 @@ class NetworkStatusDMSerializationTest { { "value": "0x123456", "type": "primary" }, { "value": "0xabcdef", "type": "secondary" } ], - "amounts": { "ETH": "1.2345" } + "amounts": { "ETH": "1.2345" }, + "yield_supply_statuses": { + "ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false } + } } """.stripJsonWhitespace() diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt index a8414dd685..b142323e74 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt @@ -22,6 +22,7 @@ internal object NetworkStatusDataModelConverter : Converter { diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt new file mode 100644 index 0000000000..d34edbbe5b --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt @@ -0,0 +1,46 @@ +package com.tangem.data.networks.converters + +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.utils.converter.TwoWayConverter +import com.tangem.utils.extensions.mapNotNullValues + +private typealias YieldSupplyStatusDataModel = Map +private typealias YieldSupplyStatusDomainModel = Map + +internal object NetworkYieldSupplyStatusConverter : + TwoWayConverter { + + override fun convert(value: YieldSupplyStatusDataModel): YieldSupplyStatusDomainModel { + return value + .mapKeys { CryptoCurrency.ID.fromValue(value = it.key) } + .mapValues { (_, yieldSupplyStatus) -> + if (yieldSupplyStatus != null) { + YieldSupplyStatus( + isActive = yieldSupplyStatus.isActive, + isInitialized = yieldSupplyStatus.isInitialized, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + ) + } else { + null + } + } + } + + override fun convertBack(value: YieldSupplyStatusDomainModel): YieldSupplyStatusDataModel { + return value + .mapKeys { (id, _) -> id.value } + .mapNotNullValues { (_, yieldSupplyStatus) -> + if (yieldSupplyStatus != null) { + NetworkStatusDM.YieldSupplyStatus( + isActive = yieldSupplyStatus.isActive, + isInitialized = yieldSupplyStatus.isInitialized, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + ) + } else { + null + } + } + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt index 44d85ee5d7..40a48b378f 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt @@ -29,6 +29,7 @@ internal object SimpleNetworkStatusConverter : Converter { diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt index 2297a75c13..a254164b1a 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -59,6 +60,22 @@ internal class NetworkStatusDataModelConverterTest { ) to Amount.NotFound, ), pendingTransactions = mapOf(), // doesn't matter + yieldSupplyStatuses = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "BCH"), + suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "BTC"), + suffix = ID.Suffix.RawID(rawId = "bitcoin"), + ) to null, + ), source = StatusSource.ACTUAL, // doesn't matter ), ), @@ -76,6 +93,13 @@ internal class NetworkStatusDataModelConverterTest { ), ), amounts = mapOf("coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO), + yieldSupplyStatuses = mapOf( + "coin⟨BCH⟩bitcoin-cash" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ), ), // endregion diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt new file mode 100644 index 0000000000..c1ef31d529 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt @@ -0,0 +1,83 @@ +package com.tangem.data.networks.converters + +import com.google.common.truth.Truth +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.models.currency.CryptoCurrency.ID +import com.tangem.domain.models.currency.CryptoCurrency.ID.Body +import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import org.junit.jupiter.api.Test + +internal class NetworkYieldSupplyStatusConverterTest { + + @Test + fun convert() { + // Arrange + val value = mapOf( + "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + "coin⟨ETH→12367123⟩ethereum" to null, + ) + + // Act + val actual = NetworkYieldSupplyStatusConverter.convert(value) + + // Assert + val expected = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to null, + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun convertBack() { + // Arrange + val value = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to null, + ) + + // Act + val actual = NetworkYieldSupplyStatusConverter.convertBack(value) + + // Assert + val expected = mapOf( + "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index cb4ad39dcd..3fe7708a7e 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -12,6 +12,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -68,6 +69,14 @@ internal class SimpleNetworkStatusConverterTest { "coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO, "coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE, ), + yieldSupplyStatuses = mapOf( + "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + "coin⟨ETH⟩ethereum" to null, + ), ), expected = SimpleNetworkStatus( id = Network.ID( @@ -104,6 +113,22 @@ internal class SimpleNetworkStatusConverterTest { ) to Amount.Loaded(value = BigDecimal.ONE), ), pendingTransactions = emptyMap(), + yieldSupplyStatuses = mapOf( + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ID( + prefix = Prefix.COIN_PREFIX, + body = Body.NetworkId(rawId = "ETH"), + suffix = ID.Suffix.RawID(rawId = "ethereum"), + ) to null, + ), source = StatusSource.CACHE, ), ).let(Result.Companion::success), @@ -178,6 +203,7 @@ internal class SimpleNetworkStatusConverterTest { ), ), amounts = emptyMap(), + yieldSupplyStatuses = emptyMap(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), @@ -193,6 +219,7 @@ internal class SimpleNetworkStatusConverterTest { selectedAddress = "0x1", availableAddresses = setOf(), amounts = emptyMap(), + yieldSupplyStatuses = emptyMap(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), @@ -217,6 +244,7 @@ internal class SimpleNetworkStatusConverterTest { ), ), amounts = emptyMap(), + yieldSupplyStatuses = emptyMap(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), diff --git a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt index d521fce1b3..f45a3fae53 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt @@ -149,6 +149,11 @@ internal class CommonNetworkStatusFetcherTest { pendingTransactions = mapOf( CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND") to emptySet(), ), + yieldSupplyStatuses = mapOf( + CryptoCurrency.ID.fromValue( + value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND", + ) to null, + ), ) }, ), diff --git a/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt b/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt index 3cc7ba1297..2350f4e822 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/utils/NetworkStatusFactoryTest.kt @@ -13,6 +13,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @@ -222,6 +223,57 @@ internal class NetworkStatusFactoryTest(private val model: Model) { currencies.last().id to setOf(), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), + ), + ), + createSuccess( + result = updateWalletManagerResultFactory.createVerifiedWithToken(), + currencies = currencies, + status = NetworkStatus.Verified( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x1", + type = NetworkAddress.Address.Type.Primary, + ), + ), + amounts = mapOf( + currencies.first().id to Amount.Loaded(BigDecimal.ONE), + currencies.last().id to Amount.NotFound, + ), + pendingTransactions = mapOf( + currencies.first().id to setOf(txInfo), + currencies.last().id to setOf(txInfo), + ), + source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), + ), + ), + createSuccess( + result = updateWalletManagerResultFactory.createVerifiedWithSuppliedToken(), + currencies = currencies, + status = NetworkStatus.Verified( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x1", + type = NetworkAddress.Address.Type.Primary, + ), + ), + amounts = mapOf( + currencies.first().id to Amount.Loaded(BigDecimal.ONE), + currencies.last().id to Amount.NotFound, + ), + pendingTransactions = mapOf( + currencies.first().id to setOf(txInfo), + currencies.last().id to setOf(txInfo), + ), + source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf( + currencies.first().id to YieldSupplyStatus( + isActive = false, + isInitialized = false, + isAllowedToSpend = false, + ), + ), ), ), // endregion From 8b9642ba7e9a514405f7ac28760fb8d0bfc33952 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 12:36:52 +0300 Subject: [PATCH 092/132] Updated on 2026-08-14 --- .../tangem/data/wallets/hot/DefaultHotWalletAccessor.kt | 1 + .../features/hotwallet/accesscode/AccessCodeModel.kt | 9 +++++---- .../feature/walletsettings/model/WalletSettingsModel.kt | 2 +- gradle/tangem_dependencies.toml | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index fdf4a50d73..9fac877712 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -104,6 +104,7 @@ class DefaultHotWalletAccessor @Inject constructor( return runCatchingSdkErrors(hotWalletId, auth) { block(UnlockHotWallet(hotWalletId, it)).also { + hotWalletPasswordRequester.successfulAuthentication() hotWalletPasswordRequester.dismiss() } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt index ede26e72e0..40ef51ebc0 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt @@ -155,6 +155,7 @@ internal class AccessCodeModel @Inject constructor( if (!shouldAskForBiometry()) return suspendCancellableCoroutine { continuation -> + var buttonClicked = false uiMessageSender.send( DialogMessage( title = resourceReference(R.string.common_attention), @@ -162,6 +163,7 @@ internal class AccessCodeModel @Inject constructor( firstAction = EventMessageAction( title = resourceReference(R.string.common_allow), onClick = { + buttonClicked = true modelScope.launch { setAskBiometryShownUseCase() walletsRepository.setUseBiometricAuthentication(true) @@ -175,6 +177,7 @@ internal class AccessCodeModel @Inject constructor( secondAction = EventMessageAction( title = resourceReference(R.string.save_user_wallet_agreement_dont_allow), onClick = { + buttonClicked = true modelScope.launch { setAskBiometryShownUseCase() if (continuation.isActive) { @@ -184,10 +187,8 @@ internal class AccessCodeModel @Inject constructor( }, ), onDismissRequest = { - modelScope.launch { - if (continuation.isActive) { - continuation.resume(Unit) - } + if (continuation.isActive && buttonClicked.not()) { + continuation.resume(Unit) } }, ), diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 85b018759e..7f83f25401 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -395,7 +395,7 @@ internal class WalletSettingsModel @Inject constructor( -> modelScope.launch { unlockHotWalletContextualUseCase.invoke(hotWalletId) .onLeft { - Timber.e("Unable to unlock wallet with id ${params.userWalletId}") + Timber.e(it, "Unable to unlock wallet with id ${params.userWalletId}") } .onRight { action() diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 25b1732ddf..ed82bec0bf 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -11,7 +11,7 @@ tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-527" +tangemHotSdk = "develop-528" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From c5e437586bb6f89bb6a07997f23f7be9a8d3edc8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 16:32:29 +0500 Subject: [PATCH 093/132] Updated on 2026-08-14 --- core/res/src/main/res/values-ja/strings.xml | 2 +- core/res/src/main/res/values/strings.xml | 21 ++++--- .../tangem/core/ui/components/label/Label.kt | 62 +++++++++++++++---- .../ui/components/label/entity/LabelUM.kt | 3 + .../main/res/drawable/ic_analytics_up_24.xml | 9 +++ .../src/main/res/drawable/ic_flash_new_24.xml | 9 +++ .../ui/src/main/res/drawable/ic_repeat_24.xml | 10 +++ .../res/drawable/ic_security_check_24.xml | 9 +++ 8 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_analytics_up_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_flash_new_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_repeat_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_security_check_24.xml diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 0ec41dbaeb..50f599d85a 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1657,7 +1657,7 @@ Aaveは世界中で何百万人もの人々に信頼されています。総貸付額は104億ドルです。 分散型・自己管理型 サービスを使用することにより、プロバイダーに同意したものとみなされます - 年間%s%の収益 + 年間%s%%の収益 Aave • 変動金利 Aave 平均%s diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 4db0f5063e..fb4b2b7a3b 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -200,6 +200,7 @@ You have not given access to your camera, please adjust your privacy settings Cancel Change + Choose account Choose action Choose network Choose token @@ -1266,6 +1267,14 @@ You receive Choose token not available + Get card + Add to your wallet and pay with your phone anywhere. + Apple Pay & Google Pay + Use your USDC balance to pay for everyday purchases seamlessly. + Everyday purchases + Your card details are protected — full control in the app. + Built-in security + Get your free Crypto Card\nin minutes This is my wallet Balances hidden Balances shown @@ -1414,14 +1423,6 @@ Unlock Scan your card to unlock access Needed unlock - Get your free Crypto Card\nin minutes - Built-in security - Your card details are protected — full control in the app. - Everyday purchases - Use your USDC balance to pay for everyday purchases seamlessly. - Apple Pay & Google Pay - Add to your wallet and pay with your phone anywhere. - Get card Blockchain is unreachable. Try later Scan card or ring This wallet has already been activated earlier.\nIf it was not done by you, please contact support.\nTangem never sells wallets along with pre-generated access codes. @@ -1732,8 +1733,8 @@ How it works? Aave is trusted by millions worldwide. Total lended value is $10.4B. Decentralized and self-custodial - By using service, you agree with provider - Earn %s% yearly + By using service, you agree with provider %1$s and %2$s + Earn %s%% yearly Aave • Variable Interest Rate Aave Avg %s diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt index 7e95a24776..86dacb20d6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt @@ -2,19 +2,22 @@ package com.tangem.core.ui.components.label import android.content.res.Configuration import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +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.TextReference @@ -48,8 +51,18 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { }, ) + val iconColor by animateColorAsState( + targetValue = when (state.style) { + LabelStyle.ACCENT -> TangemTheme.colors.icon.accent + LabelStyle.REGULAR -> TangemTheme.colors.icon.informative + LabelStyle.WARNING -> TangemTheme.colors.icon.warning + }, + ) + AnimatedContent(targetState = state.text) { text -> - Box( + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .padding(horizontal = 4.dp) .background( @@ -63,6 +76,15 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { style = TangemTheme.typography.caption1, color = textColor, ) + AnimatedVisibility(state.icon != null) { + val wrappedIcon = remember(this) { requireNotNull(state.icon) } + Icon( + imageVector = ImageVector.vectorResource(wrappedIcon), + tint = iconColor, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } } } } @@ -73,6 +95,7 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { private fun LabelPreview() { TangemThemePreview { Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp), ) { Label( @@ -81,24 +104,39 @@ private fun LabelPreview() { style = LabelStyle.REGULAR, ), ) - - Spacer(modifier = Modifier.height(8.dp)) - Label( state = LabelUM( text = TextReference.Str("Accent Label"), style = LabelStyle.ACCENT, ), ) - - Spacer(modifier = Modifier.height(8.dp)) - Label( state = LabelUM( text = TextReference.Str("Warning Label"), style = LabelStyle.WARNING, ), ) + Label( + state = LabelUM( + text = TextReference.Str("Regular Label"), + style = LabelStyle.REGULAR, + icon = R.drawable.ic_information_24, + ), + ) + Label( + state = LabelUM( + text = TextReference.Str("Accent Label"), + style = LabelStyle.ACCENT, + icon = R.drawable.ic_information_24, + ), + ) + Label( + state = LabelUM( + text = TextReference.Str("Warning Label"), + style = LabelStyle.WARNING, + icon = R.drawable.ic_information_24, + ), + ) } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt index 70b134bfe8..1be654c994 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt @@ -1,10 +1,13 @@ package com.tangem.core.ui.components.label.entity +import androidx.annotation.DrawableRes import com.tangem.core.ui.extensions.TextReference data class LabelUM( val text: TextReference, val style: LabelStyle, + @DrawableRes val icon: Int? = null, + val onIconClick: (() -> Unit)? = null, ) enum class LabelStyle { diff --git a/core/ui/src/main/res/drawable/ic_analytics_up_24.xml b/core/ui/src/main/res/drawable/ic_analytics_up_24.xml new file mode 100644 index 0000000000..be13c86516 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_analytics_up_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_flash_new_24.xml b/core/ui/src/main/res/drawable/ic_flash_new_24.xml new file mode 100644 index 0000000000..71ad60eda8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_flash_new_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_repeat_24.xml b/core/ui/src/main/res/drawable/ic_repeat_24.xml new file mode 100644 index 0000000000..deb5797a0b --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_repeat_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_security_check_24.xml b/core/ui/src/main/res/drawable/ic_security_check_24.xml new file mode 100644 index 0000000000..8a64f5924c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_security_check_24.xml @@ -0,0 +1,9 @@ + + + From c5de0ebfdc30bb62e850476a757634ad8c1cba33 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 16:32:45 +0500 Subject: [PATCH 094/132] Updated on 2026-08-14 --- app/build.gradle.kts | 2 +- .../tangem/tap/routing/utils/ChildFactory.kt | 11 +- .../com/tangem/common/routing/AppRoute.kt | 6 + .../ComposableBottomSheetComponent.kt | 9 + .../supply/api/YieldSupplyPromoComponent.kt | 16 ++ features/yield-supply/impl/build.gradle.kts | 1 + .../promo/DefaultYieldSupplyPromoComponent.kt | 56 ++++ .../impl/promo/YieldSupplyPromoConfig.kt | 6 + .../promo/di/YieldSupplyPromoBindsModule.kt | 34 +++ .../impl/promo/entity/YieldSupplyPromoUM.kt | 9 + .../model/YieldSupplyPromoClickIntents.kt | 14 + .../impl/promo/model/YieldSupplyPromoModel.kt | 51 ++++ .../impl/promo/ui/YieldSupplyPromoContent.kt | 239 ++++++++++++++++++ 13 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyPromoComponent.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/DefaultYieldSupplyPromoComponent.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/YieldSupplyPromoConfig.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/di/YieldSupplyPromoBindsModule.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoClickIntents.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 048e2f771d..7c5c71355a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -275,7 +275,7 @@ dependencies { implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.impl) implementation(projects.features.yieldSupply.api) - implementation(projects.features.yieldSupply.api) + implementation(projects.features.yieldSupply.impl) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) 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 6035f506dc..fb0bfb3588 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 @@ -34,9 +34,11 @@ import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.v2.api.SendWithSwapComponent import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent @@ -45,7 +47,6 @@ import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent import com.tangem.tap.features.welcome.component.WelcomeComponent import com.tangem.tap.routing.component.RoutingComponent.Child -import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject import com.tangem.features.walletconnect.components.WalletConnectEntryComponent as RedesignedWalletConnectComponent @@ -105,6 +106,7 @@ internal class ChildFactory @Inject constructor( private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, + private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { @@ -600,6 +602,13 @@ internal class ChildFactory @Inject constructor( componentFactory = tangemPayOnboardingComponentFactory, ) } + is AppRoute.YieldSupplyPromo -> { + createComponentChild( + context = context, + params = YieldSupplyPromoComponent.Params(route.userWalletId, route.cryptoCurrency), + componentFactory = yieldSupplyPromoComponentFactory, + ) + } } } } \ No newline at end of file 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 3ca2c4c565..448f369ac0 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 @@ -382,4 +382,10 @@ sealed class AppRoute(val path: String) : Route { data class TangemPayOnboarding( val deeplink: String, ) : AppRoute(path = "/tangem_pay_onboarding/$deeplink") + + @Serializable + data class YieldSupplyPromo( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + ) : AppRoute(path = "/yield_supply_promo/${userWalletId.stringValue}/${cryptoCurrency.symbol}") } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt index 2e3d83710f..6128a71c4a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableBottomSheetComponent.kt @@ -10,4 +10,13 @@ interface ComposableBottomSheetComponent { @Composable fun BottomSheet() +} + +fun getEmptyComposableBottomSheetComponent() = EmptyComposableBottomSheetComponent + +object EmptyComposableBottomSheetComponent : ComposableBottomSheetComponent { + override fun dismiss() {} + + @Composable + override fun BottomSheet() {} } \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyPromoComponent.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyPromoComponent.kt new file mode 100644 index 0000000000..0516efb037 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyPromoComponent.kt @@ -0,0 +1,16 @@ +package com.tangem.features.yield.supply.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +interface YieldSupplyPromoComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index 14dbb06d31..95021510f8 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(projects.core.configToggles) implementation(projects.core.decompose) implementation(projects.core.ui) + implementation(projects.core.navigation) /** Common */ implementation(projects.common.ui) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/DefaultYieldSupplyPromoComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/DefaultYieldSupplyPromoComponent.kt new file mode 100644 index 0000000000..7e9414c4a1 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/DefaultYieldSupplyPromoComponent.kt @@ -0,0 +1,56 @@ +package com.tangem.features.yield.supply.impl.promo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.getEmptyComposableBottomSheetComponent +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoModel +import com.tangem.features.yield.supply.impl.promo.ui.YieldSupplyPromoContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultYieldSupplyPromoComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: YieldSupplyPromoComponent.Params, +) : YieldSupplyPromoComponent, AppComponentContext by appComponentContext { + + private val model: YieldSupplyPromoModel = getOrCreateModel(params = params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = { _, _ -> bottomSheetChild() }, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState + val bottomSheet by bottomSheetSlot.subscribeAsState() + + YieldSupplyPromoContent( + yieldSupplyPromoUM = state, + clickIntents = model, + modifier = modifier, + ) + + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild(): ComposableBottomSheetComponent = getEmptyComposableBottomSheetComponent() + + @AssistedFactory + interface Factory : YieldSupplyPromoComponent.Factory { + override fun create( + context: AppComponentContext, + params: YieldSupplyPromoComponent.Params, + ): DefaultYieldSupplyPromoComponent + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/YieldSupplyPromoConfig.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/YieldSupplyPromoConfig.kt new file mode 100644 index 0000000000..7a87ea9946 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/YieldSupplyPromoConfig.kt @@ -0,0 +1,6 @@ +package com.tangem.features.yield.supply.impl.promo + +internal enum class YieldSupplyPromoConfig { + Apy, + Action, +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/di/YieldSupplyPromoBindsModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/di/YieldSupplyPromoBindsModule.kt new file mode 100644 index 0000000000..95e0a79cc6 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/di/YieldSupplyPromoBindsModule.kt @@ -0,0 +1,34 @@ +package com.tangem.features.yield.supply.impl.promo.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.impl.promo.DefaultYieldSupplyPromoComponent +import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoModel +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface YieldSupplyPromoBindsModule { + @Binds + @Singleton + fun provideYieldSupplyPromoComponentFactory( + impl: DefaultYieldSupplyPromoComponent.Factory, + ): YieldSupplyPromoComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyPromoModelModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyPromoModel::class) + fun provideYieldSupplyPromoModel(impl: YieldSupplyPromoModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt new file mode 100644 index 0000000000..51eefdad41 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.yield.supply.impl.promo.entity + +import com.tangem.core.ui.extensions.TextReference + +data class YieldSupplyPromoUM( + val tosLink: String, + val policyLink: String, + val title: TextReference, +) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoClickIntents.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoClickIntents.kt new file mode 100644 index 0000000000..0bf70fa048 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoClickIntents.kt @@ -0,0 +1,14 @@ +package com.tangem.features.yield.supply.impl.promo.model + +internal interface YieldSupplyPromoClickIntents { + + fun onBackClick() + + fun onApyInfoClick() + + fun onHowItWorksClick() + + fun onStartEarningClick() + + fun onUrlClick(url: String) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt new file mode 100644 index 0000000000..9dbc5ecdd9 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt @@ -0,0 +1,51 @@ +package com.tangem.features.yield.supply.impl.promo.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.routing.AppRouter +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.promo.YieldSupplyPromoConfig +import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +@ModelScoped +internal class YieldSupplyPromoModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, + private val appRouter: AppRouter, +) : Model(), YieldSupplyPromoClickIntents { + + val uiState: YieldSupplyPromoUM = YieldSupplyPromoUM( + tosLink = "https://tangem.com/terms-of-service/", // TODO replace with real link + policyLink = "https://tangem.com/privacy-policy/", // TODO replace with real link + title = resourceReference(R.string.yield_module_promo_screen_title, wrappedList("5.3")), + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + override fun onBackClick() { + appRouter.pop() + } + + override fun onApyInfoClick() { + bottomSheetNavigation.activate(YieldSupplyPromoConfig.Apy) + } + + override fun onUrlClick(url: String) { + urlOpener.openUrl(url) + } + + override fun onHowItWorksClick() { + urlOpener.openUrl("https://tangem.com/") // TODO replace with real link + } + + override fun onStartEarningClick() { + bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt new file mode 100644 index 0000000000..5839f6fb28 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt @@ -0,0 +1,239 @@ +package com.tangem.features.yield.supply.impl.promo.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.label.Label +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM +import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoClickIntents +import com.tangem.utils.StringsSigns + +@Suppress("MagicNumber") +@Composable +internal fun YieldSupplyPromoContent( + yieldSupplyPromoUM: YieldSupplyPromoUM, + clickIntents: YieldSupplyPromoClickIntents, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxWidth() + .imePadding() + .systemBarsPadding() + .padding(horizontal = 16.dp), + ) { + YieldStatusAppBar( + onBackClick = clickIntents::onBackClick, + onHowItWorksClick = clickIntents::onHowItWorksClick, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 20.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + modifier = Modifier + .background(TangemTheme.colors.icon.accent.copy(0.1f), CircleShape) + .padding(12.dp) + .size(32.dp), + ) + SpacerH(20.dp) + Text( + text = yieldSupplyPromoUM.title.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + SpacerH8() + Label( + state = LabelUM( + text = resourceReference(R.string.yield_module_promo_screen_variable_rate_info), + style = LabelStyle.REGULAR, + icon = R.drawable.ic_information_24, + onIconClick = clickIntents::onApyInfoClick, + ), + ) + SpacerH32() + PromoItems() + } + SpacerHMax() + YieldSupplyTosText( + tosLink = yieldSupplyPromoUM.tosLink, + policyLink = yieldSupplyPromoUM.policyLink, + onClick = clickIntents::onUrlClick, + ) + PrimaryButton( + text = stringResourceSafe(R.string.yield_module_start_earning), + onClick = clickIntents::onStartEarningClick, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH8() + } +} + +@Composable +private fun YieldStatusAppBar(onBackClick: () -> Unit, onHowItWorksClick: () -> Unit) { + Row( + modifier = Modifier.padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_back_24), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + modifier = Modifier.clickable(onClick = onBackClick), + ) + SpacerWMax() + Text( + text = stringResourceSafe(R.string.yield_module_promo_screen_how_it_works_button_title), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.clickable(onClick = onHowItWorksClick), + ) + } +} + +@Composable +private fun PromoItems() { + PromoItem( + icon = R.drawable.ic_flash_new_24, + title = resourceReference(R.string.yield_module_promo_screen_cash_out_title), + subtitle = resourceReference(R.string.yield_module_promo_screen_cash_out_subtitle), + ) + SpacerH24() + PromoItem( + icon = R.drawable.ic_repeat_24, + title = resourceReference(R.string.yield_module_promo_screen_auto_balance_title), + subtitle = resourceReference(R.string.yield_module_promo_screen_auto_balance_subtitle), + ) + SpacerH24() + PromoItem( + icon = R.drawable.ic_security_check_24, + title = resourceReference(R.string.yield_module_promo_screen_self_custodial_title), + subtitle = resourceReference(R.string.yield_module_promo_screen_self_custodial_subtitle), + ) +} + +@Composable +private fun PromoItem(@DrawableRes icon: Int, title: TextReference, subtitle: TextReference) { + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(icon), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Composable +private fun YieldSupplyTosText(tosLink: String, policyLink: String, onClick: (String) -> Unit) { + val tosTitle = stringResourceSafe(R.string.common_terms_of_use) + val policyTitle = stringResourceSafe(R.string.common_privacy_policy) + val fullString = stringResourceSafe(id = R.string.yield_module_promo_screen_terms_disclaimer, tosTitle, policyTitle) + val tosIndex = fullString.indexOf(tosTitle) + val policyIndex = fullString.indexOf(policyTitle) + + Text( + text = buildAnnotatedString { + append(StringsSigns.POINT_SIGN) + appendSpace() + append(fullString.substring(0, tosIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "TOS_TAG", + linkInteractionListener = { onClick(tosLink) }, + ), + block = { + appendColored( + text = fullString.substring(tosIndex, tosIndex + tosTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + append(fullString.substring(tosIndex + tosTitle.length, policyIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "POLICY_TAG", + linkInteractionListener = { onClick(policyLink) }, + ), + block = { + appendColored( + text = fullString.substring(policyIndex, policyIndex + policyTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + }, + textAlign = TextAlign.Center, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 724) +@Preview(showBackground = true, widthDp = 360, heightDp = 724, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun YieldSupplyPromoContent_Preview() { + TangemThemePreview { + YieldSupplyPromoContent( + yieldSupplyPromoUM = YieldSupplyPromoUM( + tosLink = "https://tangem.com/terms-of-service/", + policyLink = "https://tangem.com/privacy-policy/", + title = resourceReference(R.string.yield_module_promo_screen_title, wrappedList("5.3")), + ), + clickIntents = object : YieldSupplyPromoClickIntents { + override fun onBackClick() {} + override fun onApyInfoClick() {} + override fun onHowItWorksClick() {} + override fun onStartEarningClick() {} + override fun onUrlClick(url: String) {} + }, + ) + } +} + +// endregion \ No newline at end of file From 488e5aa09fda38c9b3c96b453a6cca1f64279296 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 17:40:01 +0500 Subject: [PATCH 095/132] Updated on 2026-08-14 --- .../send/v2/feeselector/ui/FeeSelectorBlockContent.kt | 2 +- .../send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt | 2 +- .../SendConfirmationNotificationsTransformerV2.kt | 8 +++++--- .../NFTSendConfirmationNotificationsTransformerV2.kt | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 2f403f5b0c..793262545b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -156,7 +156,7 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi val fiatRate = state.feeFiatRateUM Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { EllipsisText( - text = if (fiatRate != null) { + text = if (state.feeExtraInfo.isFeeConvertibleToFiat && fiatRate != null) { getFiatString( value = state.selectedFeeItem.fee.amount.value, rate = fiatRate.rate, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index d9aea80f8c..0027bda872 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -170,7 +170,7 @@ private fun FeeSelectorItems( ).fee(canBeLower = state.feeExtraInfo.isFeeApproximate) }, ), - postDot = if (feeFiatRateUM != null) { + postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { getFiatReference( value = item.fee.amount.value, rate = feeFiatRateUM.rate, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt index 22e99488fc..163e6764b0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt @@ -71,7 +71,9 @@ internal class SendConfirmationNotificationsTransformerV2( val fiatAmountValue = amountUM.amountTextField.fiatAmount.value val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) } - val fiatSendingValue = if (feeSelectorUM.feeFiatRateUM != null) { + val isFeeConvertibleToFiat = feeSelectorUM.feeExtraInfo.isFeeConvertibleToFiat + + val fiatSendingValue = if (isFeeConvertibleToFiat) { fiatFeeValue?.let { fiatAmountValue?.plus(it) } } else { fiatAmountValue @@ -85,7 +87,7 @@ internal class SendConfirmationNotificationsTransformerV2( } val fiatFee = formatFooterFiatFee( amount = fee.amount.copy(value = fiatFeeValue), - isFeeConvertibleToFiat = feeSelectorUM.feeFiatRateUM != null, + isFeeConvertibleToFiat = isFeeConvertibleToFiat, isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate, appCurrency = appCurrency, ) @@ -98,7 +100,7 @@ internal class SendConfirmationNotificationsTransformerV2( ) } else { resourceReference( - id = if (feeSelectorUM.feeFiatRateUM != null) { + id = if (isFeeConvertibleToFiat) { R.string.send_summary_transaction_description } else { R.string.send_summary_transaction_description_no_fiat_fee diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt index d69431a549..2aeb3ab7e0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt @@ -61,10 +61,11 @@ internal class NFTSendConfirmationNotificationsTransformerV2( val fee = feeSelectorUM?.selectedFeeItem?.fee ?: return TextReference.EMPTY val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) } + val isFeeConvertibleToFiat = feeSelectorUM.feeExtraInfo.isFeeConvertibleToFiat val fiatFee = formatFooterFiatFee( amount = fee.amount.copy(value = fiatFeeValue), - isFeeConvertibleToFiat = feeSelectorUM.feeFiatRateUM != null, + isFeeConvertibleToFiat = isFeeConvertibleToFiat, isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate, appCurrency = appCurrency, ) @@ -77,7 +78,7 @@ internal class NFTSendConfirmationNotificationsTransformerV2( ) } else { resourceReference( - id = if (feeSelectorUM.feeFiatRateUM != null) { + id = if (isFeeConvertibleToFiat) { R.string.send_summary_transaction_description } else { R.string.send_summary_transaction_description_no_fiat_fee From 90ae7070a1ef34bac9f9d300024cc71084aa3255 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 18:26:05 +0500 Subject: [PATCH 096/132] Updated on 2026-08-14 --- .../model/FeeSelectorAlertFactory.kt | 9 ++++---- .../v2/send/confirm/model/SendConfirmModel.kt | 21 +++++++++++++++++-- .../confirm/model/NFTSendConfirmModel.kt | 21 +++++++++++++++++-- .../fee/model/SendFeeAlertFactory.kt | 9 ++++---- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt index 2597f5a9d6..43091d08a1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt @@ -105,11 +105,12 @@ internal class FeeSelectorAlertFactory @Inject constructor( DialogMessage( message = resourceReference(id = R.string.send_notification_high_fee_title), dismissOnFirstAction = true, + onDismissRequest = { stopAction() }, firstActionBuilder = { - okAction { proceedAction(); onDismissRequest() } - }, - secondActionBuilder = { - cancelAction { stopAction(); onDismissRequest() } + EventMessageAction( + title = resourceReference(R.string.common_understand), + onClick = { stopAction(); onDismissRequest() }, + ) }, ), ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index 2dace129a0..94492fe17c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -43,6 +43,8 @@ import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.entity.FeeNonce import com.tangem.features.send.v2.api.params.FeeSelectorParams.FeeStateConfiguration import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger @@ -95,6 +97,8 @@ internal class SendConfirmModel @Inject constructor( private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger, private val notificationsUpdateListener: SendNotificationsUpdateListener, private val alertFactory: SendConfirmAlertFactory, @@ -275,7 +279,11 @@ internal class SendConfirmModel @Inject constructor( verifyAndSendTransaction() } else { modelScope.launch { - sendFeeCheckReloadTrigger.triggerCheckUpdate() + if (uiState.value.isRedesignEnabled) { + feeSelectorCheckReloadTrigger.triggerCheckUpdate() + } else { + sendFeeCheckReloadTrigger.triggerCheckUpdate() + } } } } @@ -506,8 +514,17 @@ internal class SendConfirmModel @Inject constructor( private fun subscribeOnCheckFeeResultUpdates() { sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() + if (isFeeResultSuccess) { + _uiState.update(SendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + feeSelectorCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() if (isFeeResultSuccess) { - sendIdleTimer = SystemClock.elapsedRealtime() _uiState.update(SendConfirmSendingStateTransformer(isSending = true)) verifyAndSendTransaction() } else { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index 786c386413..0e582b5752 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -33,6 +33,8 @@ import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.v2.common.CommonSendRoute @@ -82,6 +84,8 @@ internal class NFTSendConfirmModel @Inject constructor( private val notificationsUpdateListener: SendNotificationsUpdateListener, private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, private val alertFactory: SendConfirmAlertFactory, private val urlOpener: UrlOpener, private val shareManager: ShareManager, @@ -206,7 +210,11 @@ internal class NFTSendConfirmModel @Inject constructor( verifyAndSendTransaction() } else { modelScope.launch { - sendFeeCheckReloadTrigger.triggerCheckUpdate() + if (uiState.value.isRedesignEnabled) { + feeSelectorCheckReloadTrigger.triggerCheckUpdate() + } else { + sendFeeCheckReloadTrigger.triggerCheckUpdate() + } } } } @@ -372,8 +380,17 @@ internal class NFTSendConfirmModel @Inject constructor( private fun subscribeOnCheckFeeResultUpdates() { sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() + if (isFeeResultSuccess) { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + feeSelectorCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + sendIdleTimer = SystemClock.elapsedRealtime() if (isFeeResultSuccess) { - sendIdleTimer = SystemClock.elapsedRealtime() _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) verifyAndSendTransaction() } else { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt index 107c608bb2..4f227ac46c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt @@ -100,11 +100,12 @@ internal class SendFeeAlertFactory @Inject constructor( DialogMessage( message = resourceReference(id = R.string.send_notification_high_fee_title), dismissOnFirstAction = true, + onDismissRequest = { stopAction() }, firstActionBuilder = { - okAction { proceedAction(); onDismissRequest() } - }, - secondActionBuilder = { - cancelAction { stopAction(); onDismissRequest() } + EventMessageAction( + title = resourceReference(R.string.common_understand), + onClick = { stopAction(); onDismissRequest() }, + ) }, ), ) From d349f986c5d39a48e4ea548bd60c0c8663649fdc Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 14:02:32 +0500 Subject: [PATCH 097/132] Updated on 2026-08-14 --- .../currency/UserTokensResponseAddressesEnricherTest.kt | 3 +++ .../walletmanager/UpdateWalletManagerResultFactoryTest.kt | 6 +++--- .../kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt | 3 +++ .../com/tangem/domain/tokens/mock/MockTokensStates.kt | 3 +++ .../feeSelector/utils/FeeCalculationUtilsTest.kt | 1 + .../wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt | 1 + gradle/tangem_dependencies.toml | 4 ++-- 7 files changed, 16 insertions(+), 5 deletions(-) diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt index aed68d658d..77b27593e6 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricherTest.kt @@ -81,6 +81,7 @@ class UserTokensResponseAddressesEnricherTest { }, amounts = emptyMap(), pendingTransactions = emptyMap(), + yieldSupplyStatuses = emptyMap(), source = StatusSource.ACTUAL, ), ), @@ -124,6 +125,7 @@ class UserTokensResponseAddressesEnricherTest { }, amounts = emptyMap(), pendingTransactions = emptyMap(), + yieldSupplyStatuses = emptyMap(), source = StatusSource.ACTUAL, ), ), @@ -161,6 +163,7 @@ class UserTokensResponseAddressesEnricherTest { }, amounts = emptyMap(), pendingTransactions = emptyMap(), + yieldSupplyStatuses = emptyMap(), source = StatusSource.ACTUAL, ), ), diff --git a/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt index 9df3c38d66..4c5c929262 100644 --- a/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt +++ b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt @@ -188,7 +188,7 @@ internal class UpdateWalletManagerResultFactoryTest { ), currenciesAmounts = setOf( UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.ONE), - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( value = BigDecimal.ZERO, currencyRawId = usdtToken.id?.let(CryptoCurrency::RawID), contractAddress = usdtToken.contractAddress, @@ -416,7 +416,7 @@ internal class UpdateWalletManagerResultFactoryTest { ), currenciesAmounts = setOf( UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.ZERO), - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( value = BigDecimal.ZERO, currencyRawId = usdtToken.id?.let(CryptoCurrency::RawID), contractAddress = usdtToken.contractAddress, @@ -477,7 +477,7 @@ internal class UpdateWalletManagerResultFactoryTest { ), currenciesAmounts = setOf( UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.TEN), - UpdateWalletManagerResult.CryptoCurrencyAmount.Token( + UpdateWalletManagerResult.CryptoCurrencyAmount.Token.BasicToken( value = BigDecimal.TEN, currencyRawId = usdtToken.id?.let(CryptoCurrency::RawID), contractAddress = usdtToken.contractAddress, diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt index 8f3529b210..d856cd5af9 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt @@ -97,6 +97,7 @@ internal object MockNetworks { defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), ), ) @@ -113,6 +114,7 @@ internal object MockNetworks { defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), ), ) @@ -130,6 +132,7 @@ internal object MockNetworks { defaultAddress = NetworkAddress.Address(value = "mock", NetworkAddress.Address.Type.Primary), ), source = StatusSource.ACTUAL, + yieldSupplyStatuses = mapOf(), ), ) } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt index 555289ed3d..a9b9b31d66 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokensStates.kt @@ -156,6 +156,7 @@ internal object MockTokensStates { ).address, yieldBalance = null, sources = CryptoCurrencyStatus.Sources(), + yieldSupplyStatus = null, ) is QuoteStatus.Data -> CryptoCurrencyStatus.Loaded( amount = amount, @@ -167,6 +168,7 @@ internal object MockTokensStates { networkAddress = requireNotNull(networkStatus.value as? NetworkStatus.Verified).address, yieldBalance = null, sources = CryptoCurrencyStatus.Sources(), + yieldSupplyStatus = null, ) } status.copy(value = value) @@ -185,6 +187,7 @@ internal object MockTokensStates { ).address, yieldBalance = null, sources = CryptoCurrencyStatus.Sources(), + yieldSupplyStatus = null, ), ) } diff --git a/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt b/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt index 76958ee240..7cfd18cb02 100644 --- a/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt +++ b/features/send-v2/api/src/test/java/com/tangem/features/send/v2/api/subcomponents/feeSelector/utils/FeeCalculationUtilsTest.kt @@ -172,6 +172,7 @@ class FeeCalculationUtilsTest { hasCurrentNetworkTransactions = false, pendingTransactions = emptySet(), networkAddress = mockk(relaxed = true), + yieldSupplyStatus = null, sources = CryptoCurrencyStatus.Sources(), ) } else { diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index 29a42feb84..e4fde45099 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -795,6 +795,7 @@ class DefaultPromoDeeplinkHandlerTest { amounts = emptyMap(), pendingTransactions = emptyMap(), source = StatusSource.ACTUAL, + yieldSupplyStatuses = emptyMap(), ) return NetworkStatus(network = network, value = value) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 8b6c15d617..afd79d14ae 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,8 +5,8 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -#tangemBlockchainSdk = "develop-1222" -tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds +tangemBlockchainSdk = "develop-1225" +#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" From 4b963c9e3f54d9a26cdc058ecb423febc92ee76f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 16:01:54 +0300 Subject: [PATCH 098/132] Updated on 2026-08-14 --- .../tangem/core/ui/components/FullScreen.kt | 11 ++++++++--- .../welcome/impl/ui/WelcomeSelectWallet.kt | 18 ++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt b/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt index 965c9c4cf0..fff3c42836 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/FullScreen.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.components import android.annotation.SuppressLint import android.content.Context import android.graphics.PixelFormat +import android.os.Build import android.view.KeyEvent import android.view.View import android.view.WindowManager @@ -100,8 +101,12 @@ private class FullScreenLayout( width = WindowManager.LayoutParams.MATCH_PARENT height = WindowManager.LayoutParams.MATCH_PARENT format = PixelFormat.TRANSLUCENT - flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or - WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + + if (focusable && Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + } else { + flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + } } fun show() { @@ -142,7 +147,7 @@ private class FullScreenLayout( fun dispose() { dismiss() - setViewTreeSavedStateRegistryOwner(null) + setViewTreeLifecycleOwner(null) setViewTreeSavedStateRegistryOwner(null) setViewTreeViewModelStoreOwner(null) } diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt index 7e9e671696..d47a0f2311 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.tangem.common.ui.userwallet.UserWalletItem @@ -43,10 +44,9 @@ internal fun AnimatedContentScope.WelcomeSelectWallet(state: WelcomeUM.SelectWal .statusBarsPadding(), ) { TopBar(state) - TitleText() - SpacerH12() var actualWallets by remember { mutableStateOf>(persistentListOf()) } + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } Box(modifier = Modifier.weight(1f)) { LazyColumn( @@ -58,19 +58,26 @@ internal fun AnimatedContentScope.WelcomeSelectWallet(state: WelcomeUM.SelectWal ) + fadeIn(tween(delayMillis = 300)), exit = fadeOut(), ) - .fillMaxHeight() - .padding(horizontal = 16.dp), + .fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + item { + TitleText(modifier = Modifier.padding(bottom = 4.dp)) + } itemsIndexed(actualWallets) { index, walletState -> UserWalletItem( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), state = walletState, blockColors = TangemBlockCardColors.copy( containerColor = TangemTheme.colors.field.primary, ), ) } + item { + SpacerH(128.dp + bottomBarHeight) + } } BottomFade(modifier = Modifier.align(Alignment.BottomCenter)) @@ -115,7 +122,6 @@ private fun AnimatedContentScope.TopBar(state: WelcomeUM.SelectWallet, modifier: ) .padding( top = 16.dp, - bottom = 16.dp, start = 16.dp, ) .fillMaxWidth(), From c800f1f586db401e56da0674becc6f6a8ccbd25a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 17:04:03 +0000 Subject: [PATCH 099/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index adeb5e20db..ed82bec0bf 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1226" +tangemBlockchainSdk = "develop-1222" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-528" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 18b9831f69e2798149e1a551a1f394226f8ba0bf Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 18:36:12 +0000 Subject: [PATCH 100/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index adeb5e20db..ed82bec0bf 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1226" +tangemBlockchainSdk = "develop-1222" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-528" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 2d79953060e235aecc09e47d4deab9175778aff5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Sep 2025 12:56:45 +0300 Subject: [PATCH 101/132] Updated on 2026-08-14 --- .../DefaultUserWalletsListRepository.kt | 44 ++++++++++--------- .../core/wallets/UserWalletsListRepository.kt | 9 ++-- .../CreateWalletSelectionModel.kt | 11 ++++- .../start/AddExistingWalletStartModel.kt | 9 +++- .../intents/WalletWarningsClickIntents.kt | 2 +- .../welcome/impl/model/WelcomeModel.kt | 2 +- 6 files changed, 48 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index fd30adec16..c4a0b716f7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.raise.either import arrow.core.right +import com.tangem.common.CompletionResult import com.tangem.common.doOnFailure import com.tangem.common.doOnSuccess import com.tangem.common.flatMap @@ -210,6 +211,7 @@ internal class DefaultUserWalletsListRepository( } } + @Suppress("CyclomaticComplexMethod") override suspend fun unlock( userWalletId: UserWalletId, unlockMethod: UserWalletsListRepository.UnlockMethod, @@ -257,31 +259,33 @@ internal class DefaultUserWalletsListRepository( raise(UnlockWalletError.UnableToUnlock) } } - UserWalletsListRepository.UnlockMethod.Scan -> { + is UserWalletsListRepository.UnlockMethod.Scan -> { if (userWallet !is UserWallet.Cold) { raise(UnlockWalletError.UnableToUnlock) } - tangemSdkManagerProvider().scanProduct() - .doOnSuccess { scanResponse -> - val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build() - - if (expectedId != userWallet.walletId) { - raise(UnlockWalletError.ScannedCardWalletNotMatched) - } - - val encryptionKey = UserWalletEncryptionKey( - walletId = userWallet.walletId, - encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock), - ) - - sensitiveInformationRepository.getAll(listOf(encryptionKey)) - .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } - .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) } - } - .doOnFailure { - raise(UnlockWalletError.UserCancelled) + val scanResponse = unlockMethod.scanResponse ?: run { + val res = tangemSdkManagerProvider().scanProduct() + when (res) { + is CompletionResult.Failure -> raise(UnlockWalletError.UserCancelled) + is CompletionResult.Success -> res.data } + } + + val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build() + + if (expectedId != userWallet.walletId) { + raise(UnlockWalletError.ScannedCardWalletNotMatched) + } + + val encryptionKey = UserWalletEncryptionKey( + walletId = userWallet.walletId, + encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock), + ) + + sensitiveInformationRepository.getAll(listOf(encryptionKey)) + .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } + .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) } } } } diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt index fbcfeb9a0c..c3704524c2 100644 --- a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt @@ -7,6 +7,7 @@ import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.core.wallets.error.SelectWalletError import com.tangem.domain.core.wallets.error.SetLockError import com.tangem.domain.core.wallets.error.UnlockWalletError +import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.StateFlow @@ -133,10 +134,10 @@ interface UserWalletsListRepository { data object NoLock : LockMethod() } - enum class UnlockMethod { - Biometric, - AccessCode, - Scan, + sealed class UnlockMethod { + data object Biometric : UnlockMethod() + data object AccessCode : UnlockMethod() + data class Scan(val scanResponse: ScanResponse? = null) : UnlockMethod() } } diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 26ce10e204..d5c27dd004 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -140,13 +140,20 @@ internal class CreateWalletSelectionModel @Inject constructor( return } - saveWalletUseCase(userWallet).fold( + saveWalletUseCase(userWallet = userWallet).fold( ifLeft = { delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") - is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet) + is SaveWalletError.WalletAlreadySaved -> { + userWalletsListRepository.unlock( + userWalletId = userWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + ).onRight { + appRouter.replaceAll(AppRoute.Wallet) + } + } } }, ifRight = { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt index e39dd4adbb..e8d5c52060 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/AddExistingWalletStartModel.kt @@ -146,7 +146,14 @@ internal class AddExistingWalletStartModel @Inject constructor( setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") - is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet) + is SaveWalletError.WalletAlreadySaved -> { + userWalletsListRepository.unlock( + userWalletId = userWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + ).onRight { + appRouter.replaceAll(AppRoute.Wallet) + } + } } }, ifRight = { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 203128c7ce..55390d5b9e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -178,7 +178,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( .onLeft { val selectedUserWallet = getSelectedUserWallet() ?: return@onLeft val method = when (selectedUserWallet) { - is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan + is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan() is UserWallet.Hot -> UserWalletsListRepository.UnlockMethod.AccessCode } userWalletsListRepository.unlock(stateHolder.getSelectedWalletId(), method) diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index d1128d02f2..4b7bdf5496 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -208,7 +208,7 @@ internal class WelcomeModel @Inject constructor( } val unlockMethod = when (userWallet) { - is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan + is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan() is UserWallet.Hot -> { uiState.value = WelcomeUM.Empty UserWalletsListRepository.UnlockMethod.AccessCode From b9c541d416c4a4207a23254e90adc18020dff97c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Sep 2025 11:44:13 +0400 Subject: [PATCH 102/132] Updated on 2026-08-14 --- app/build.gradle.kts | 7 ++++-- features/kyc/mock/.gitignore | 1 + features/kyc/mock/build.gradle.kts | 22 ++++++++++++++++++ .../tangem/features/kyc/MockKycComponent.kt | 23 +++++++++++++++++++ .../tangem/features/kyc/di/FeatureModule.kt | 16 +++++++++++++ .../onboarding/impl/build.gradle.kts | 1 + .../DefaultOnboardVisaDeepLinkHandler.kt | 8 ++++++- settings.gradle.kts | 4 ++-- 8 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 features/kyc/mock/.gitignore create mode 100644 features/kyc/mock/build.gradle.kts create mode 100644 features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt create mode 100644 features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c5c71355a..523778d665 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -256,8 +256,11 @@ dependencies { implementation(projects.features.hotWallet.api) implementation(projects.features.hotWallet.impl) implementation(projects.features.kyc.api) - //TODO disable for release because of the permissions - // implementation(projects.features.kyc.impl) + debugImplementation(projects.features.kyc.impl) + internalImplementation(projects.features.kyc.impl) + mockedImplementation(projects.features.kyc.impl) + releaseImplementation(projects.features.kyc.mock) + externalImplementation(projects.features.kyc.mock) implementation(projects.features.welcome.api) implementation(projects.features.welcome.impl) implementation(projects.features.createWalletSelection.api) diff --git a/features/kyc/mock/.gitignore b/features/kyc/mock/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/kyc/mock/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/kyc/mock/build.gradle.kts b/features/kyc/mock/build.gradle.kts new file mode 100644 index 0000000000..a64c1f8a89 --- /dev/null +++ b/features/kyc/mock/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.kyc.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.kyc.api) + + implementation(projects.core.decompose) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt new file mode 100644 index 0000000000..2f1b4ef303 --- /dev/null +++ b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.kyc + +import com.tangem.core.decompose.context.AppComponentContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Mocking it for release/external builds to exclude SumSub dependency + */ +class MockKycComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, +) : KycComponent, AppComponentContext by appComponentContext { + + override fun launch() { + /* no op */ + } + + @AssistedFactory + interface Factory : KycComponent.Factory { + override fun create(appComponentContext: AppComponentContext): MockKycComponent + } +} \ No newline at end of file diff --git a/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt new file mode 100644 index 0000000000..94de8a68b8 --- /dev/null +++ b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/di/FeatureModule.kt @@ -0,0 +1,16 @@ +package com.tangem.features.kyc.di + +import com.tangem.features.kyc.KycComponent +import com.tangem.features.kyc.MockKycComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface FeatureModule { + + @Binds + fun bindComponentFactory(impl: MockKycComponent.Factory): KycComponent.Factory +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/build.gradle.kts b/features/tangempay/onboarding/impl/build.gradle.kts index 8ebb588cdd..1d205ff513 100644 --- a/features/tangempay/onboarding/impl/build.gradle.kts +++ b/features/tangempay/onboarding/impl/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { /** Features api */ implementation(projects.features.tangempay.onboarding.api) + implementation(projects.features.tangempay.details.api) implementation(projects.features.kyc.api) /** Compose */ diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt index a843aade0a..3b10b702a9 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultOnboardVisaDeepLinkHandler.kt @@ -3,6 +3,7 @@ package com.tangem.features.tangempay.deeplink import android.net.Uri import dagger.assisted.Assisted import com.tangem.common.routing.AppRoute +import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.common.routing.AppRouter import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -10,10 +11,15 @@ import dagger.assisted.AssistedInject internal class DefaultOnboardVisaDeepLinkHandler @AssistedInject constructor( @Assisted uri: Uri, appRouter: AppRouter, + tangemPayFeatureToggles: TangemPayFeatureToggles, ) : OnboardVisaDeepLinkHandler { init { - appRouter.push(AppRoute.TangemPayOnboarding(uri.toString())) + if (tangemPayFeatureToggles.isTangemPayEnabled) { + appRouter.push(AppRoute.TangemPayOnboarding(uri.toString())) + } else { + appRouter.push(AppRoute.Home()) + } } @AssistedFactory diff --git a/settings.gradle.kts b/settings.gradle.kts index c70d2637a1..305ea20cc8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -263,8 +263,8 @@ include(":features:hot-wallet:api") include(":features:hot-wallet:impl") include(":features:kyc:api") -//TODO disable for release because of the permissions -// include(":features:kyc:impl") +include(":features:kyc:impl") +include(":features:kyc:mock") include(":features:tangempay:main:api") include(":features:tangempay:main:impl") From 8bfc3d7f8cac6ac1d3e7b27ace41f7c05e62a727 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Sep 2025 20:30:56 +0700 Subject: [PATCH 103/132] Updated on 2026-08-14 --- .../datasource/di/WalletConnectModule.kt | 13 +++- .../DefaultWalletConnectStore.kt | 33 +++++++++ .../local/walletconnect/WalletConnectStore.kt | 6 ++ .../di/WalletConnectDataModule.kt | 16 ++-- .../ethereum/WcEthSendTransactionUseCase.kt | 7 +- .../ethereum/WcEthSignTransactionUseCase.kt | 7 +- .../pair/DefaultWcPairUseCase.kt | 48 +++++++----- .../walletconnect/pair/WcPairSdkDelegate.kt | 73 +++++++++---------- .../sessions/DefaultWcSessionsManager.kt | 36 ++++----- .../data/walletconnect/utils/WcScope.kt | 13 ++++ .../walletconnect/DefaultWcPairUseCaseTest.kt | 27 ++----- .../walletconnect/model/WcSessionDTO.kt | 11 +++ .../repository/WcSessionsManager.kt | 1 - .../method/BlockAidTransactionCheck.kt | 11 +-- .../usecase/pair/WcPairUseCase.kt | 3 +- .../connections/model/WcPairModel.kt | 8 +- 16 files changed, 182 insertions(+), 131 deletions(-) create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt index 4c5f149013..e790ce4dc7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt @@ -8,6 +8,7 @@ import com.tangem.datasource.local.walletconnect.DefaultWalletConnectStore import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.setTypes +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import com.tangem.domain.walletconnect.model.WcSessionDTO import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -30,6 +31,7 @@ object WalletConnectModule { @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, ): WalletConnectStore { + val scope = CoroutineScope(context = dispatchers.io + SupervisorJob()) return DefaultWalletConnectStore( persistenceStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( @@ -38,7 +40,16 @@ object WalletConnectModule { defaultValue = emptySet(), ), produceFile = { context.dataStoreFile(fileName = "wallet_connect_sessions") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + scope = scope, + ), + pendingApprovalSessionsStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = setTypes(), + defaultValue = emptySet(), + ), + produceFile = { context.dataStoreFile(fileName = "wallet_connect_pending_approval_sessions") }, + scope = scope, ), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt index 79da8620de..d63e5bb1b6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt @@ -1,18 +1,39 @@ package com.tangem.datasource.local.walletconnect import androidx.datastore.core.DataStore +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import com.tangem.domain.walletconnect.model.WcSessionDTO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.transform +import org.joda.time.DateTime internal typealias WcSessionCollection = Set internal class DefaultWalletConnectStore( private val persistenceStore: DataStore, + private val pendingApprovalSessionsStore: DataStore>, ) : WalletConnectStore { override val sessions: Flow get() = persistenceStore.data + override val pendingApproval: Flow> + get() = pendingApprovalSessionsStore.data + .transform { pendingApprovalSet -> + val now = DateTime.now() + val expired = pendingApprovalSet + .filterTo(mutableSetOf()) { it.expiredTime < now.millis } + val shouldSomeClear = expired.isNotEmpty() + val actualData = if (shouldSomeClear) { + removePendingApproval(expired) + } else { + pendingApprovalSet + } + emit(actualData) + } + .distinctUntilChanged() + override suspend fun saveSessions(sessions: WcSessionCollection) { persistenceStore.updateData { data -> data.plus(sessions) } } @@ -20,4 +41,16 @@ internal class DefaultWalletConnectStore( override suspend fun removeSessions(sessions: WcSessionCollection) { persistenceStore.updateData { data -> data.minus(sessions) } } + + override suspend fun savePendingApproval( + sessions: Set, + ): Set { + return pendingApprovalSessionsStore.updateData { data -> data.plus(sessions) } + } + + override suspend fun removePendingApproval( + sessions: Set, + ): Set { + return pendingApprovalSessionsStore.updateData { data -> data.minus(sessions) } + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt index 4f3a8397b8..baad949d54 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt @@ -1,5 +1,6 @@ package com.tangem.datasource.local.walletconnect +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import com.tangem.domain.walletconnect.model.WcSessionDTO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -7,6 +8,8 @@ import kotlinx.coroutines.flow.first interface WalletConnectStore { val sessions: Flow + val pendingApproval: Flow> + suspend fun findSessionByTopic(topic: String) = sessions.first().find { it.topic == topic } suspend fun saveSessions(sessions: WcSessionCollection) @@ -14,4 +17,7 @@ interface WalletConnectStore { suspend fun removeSessions(sessions: WcSessionCollection) suspend fun removeSession(session: WcSessionDTO) = removeSessions(setOf(session)) + + suspend fun savePendingApproval(sessions: Set): Set + suspend fun removePendingApproval(sessions: Set): Set } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index a83cefd1ae..780142a298 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -17,6 +17,7 @@ import com.tangem.data.walletconnect.respond.WcRespondService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.data.walletconnect.utils.WcNetworksConverter +import com.tangem.data.walletconnect.utils.WcScope import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.walletconnect.WalletConnectStore @@ -36,8 +37,6 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -80,7 +79,10 @@ internal object WalletConnectDataModule { @Provides @Singleton - fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate() + fun sdkDelegate(wcScope: WcScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate( + scope = wcScope, + store = store, + ) @Provides @Singleton @@ -90,15 +92,15 @@ internal object WalletConnectDataModule { getWallets: GetWalletsUseCase, wcNetworksConverter: WcNetworksConverter, analytics: AnalyticsEventHandler, + wcScope: WcScope, ): DefaultWcSessionsManager { - val scope = CoroutineScope(SupervisorJob() + dispatchers.io) return DefaultWcSessionsManager( store = store, dispatchers = dispatchers, getWallets = getWallets, wcNetworksConverter = wcNetworksConverter, analytics = analytics, - scope = scope, + scope = wcScope, ) } @@ -106,6 +108,10 @@ internal object WalletConnectDataModule { @Singleton fun wcSessionsManager(default: DefaultWcSessionsManager): WcSessionsManager = default + @Provides + @Singleton + fun wcScope(dispatchers: CoroutineDispatcherProvider): WcScope = WcScope(dispatchers) + @Provides @Singleton fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt index e82c497072..165fe556d9 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt @@ -74,12 +74,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( logoUrl = tokenInfo.logoUrl, chainId = tokenInfo.chainId, ) - BlockAidTransactionCheck.Result.Approval( - result = result, - approval = this, - tokenInfo = tokenInfo, - isMutable = true, - ) + BlockAidTransactionCheck.Result.Approval(result = result) } } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt index 8cfe678df0..e71f37533d 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSignTransactionUseCase.kt @@ -68,12 +68,7 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor( chainId = tokenInfo.chainId, ) } - BlockAidTransactionCheck.Result.Approval( - result = result, - approval = this, - tokenInfo = tokenInfo, - isMutable = true, - ) + BlockAidTransactionCheck.Result.Approval(result = result) } } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index 2383bb2e9d..529f7a9302 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -9,13 +9,11 @@ import com.domain.blockaid.models.dapp.DAppData import com.reown.walletkit.client.Wallet import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.walletconnect.utils.WC_TAG -import com.tangem.data.walletconnect.utils.WcSdkSessionConverter import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.* import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData -import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.pair.WcPairState import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase import dagger.assisted.Assisted @@ -25,12 +23,12 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import org.joda.time.DateTime +import org.joda.time.Duration import timber.log.Timber import java.net.URI @Suppress("LongParameterList") internal class DefaultWcPairUseCase @AssistedInject constructor( - private val sessionsManager: WcSessionsManager, private val associateNetworksDelegate: AssociateNetworksDelegate, private val caipNamespaceDelegate: CaipNamespaceDelegate, private val sdkDelegate: WcPairSdkDelegate, @@ -105,24 +103,29 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( // start flow of approving in wc sdk emit(WcPairState.Approving.Loading(sessionForApprove)) + + val connectingTime = DateTime.now().millis + val expiredTime = connectingTime + Duration + .standardMinutes(PENDING_SESSION_EXPIRED_DURATION_MIN) + .millis + val sessionDTO = WcSessionDTO( + topic = "", + walletId = sessionForApprove.wallet.walletId, + url = sdkVerifyContext.getDappOriginUrl(), + securityStatus = proposalState.dAppSession.securityStatus, + connectingTime = connectingTime, + ) + val pendingSessionForSave = WcPendingApprovalSessionDTO( + pairingTopic = sdkSessionProposal.pairingTopic, + session = sessionDTO, + expiredTime = expiredTime, + ) + val either = walletKitApproveSession( + pendingSessionForSave = pendingSessionForSave, sessionForApprove = sessionForApprove, sdkSessionProposal = sdkSessionProposal, ).map { settledSession -> - val newSession = WcSession( - wallet = sessionForApprove.wallet, - sdkModel = WcSdkSessionConverter.convert( - value = WcSdkSessionConverter.Input( - originUrl = sdkVerifyContext.getDappOriginUrl(), - session = settledSession.session, - ), - ), - securityStatus = proposalState.dAppSession.securityStatus, - networks = sessionForApprove.network.toSet(), - connectingTime = DateTime.now().millis, - showWalletInfo = proposalState.dAppSession.proposalNetwork.keys.size > 1, - ) - sessionsManager.saveSession(newSession) analytics.send( WcAnalyticEvents.DAppConnected( sessionProposal = proposalState.dAppSession, @@ -130,7 +133,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( securityStatus = proposalState.dAppSession.securityStatus, ), ) - newSession + proposalState.dAppSession.dAppMetaData }.onLeft { analytics.send( WcAnalyticEvents.DAppConnectionFailed( @@ -170,9 +173,10 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( } private suspend fun walletKitApproveSession( + pendingSessionForSave: WcPendingApprovalSessionDTO, sessionForApprove: WcSessionApprove, sdkSessionProposal: Wallet.Model.SessionProposal, - ): Either = try { + ): Either = try { val namespaces = caipNamespaceDelegate.associate( sdkSessionProposal, sessionForApprove, @@ -181,7 +185,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( proposerPublicKey = sdkSessionProposal.proposerPublicKey, namespaces = namespaces, ) - sdkDelegate.approve(sessionApprove) + sdkDelegate.approve(pendingSessionForSave, sessionApprove) } catch (e: Throwable) { Timber.tag(WC_TAG).e(e, "Failed to sdk approve session $pairRequest") WcPairError.ApprovalFailed(e.message.orEmpty()).left() @@ -233,6 +237,10 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( }, ) + private companion object { + const val PENDING_SESSION_EXPIRED_DURATION_MIN = 15L + } + private sealed interface TerminalAction { data class Approve(val sessionForApprove: WcSessionApprove) : TerminalAction data object Reject : TerminalAction diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt index 2e71915a53..a9d9030333 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -6,25 +6,45 @@ import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.data.walletconnect.utils.WcScope import com.tangem.data.walletconnect.utils.WcSdkObserver +import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed +import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.* import timber.log.Timber import kotlin.coroutines.resume import kotlin.time.Duration.Companion.seconds -internal class WcPairSdkDelegate : WcSdkObserver { +internal class WcPairSdkDelegate( + private val scope: WcScope, + private val store: WalletConnectStore, +) : WcSdkObserver { private val onSessionProposal = Channel>() private val onSdkErrorCallback = Channel() - private val onSessionSettleResponse = Channel() + private val onSessionSettleCallback = Channel() + + init { + onSessionSettleCallback.receiveAsFlow() + .filterIsInstance() + .buffer() + .onEach { settledResponse -> + val savedPending = store.pendingApproval.first() + val settledSession = settledResponse.session + val savedPendingSession = savedPending + .find { it.pairingTopic == settledSession.pairingTopic } + ?: return@onEach + store.saveSession(savedPendingSession.session.copy(topic = settledSession.topic)) + store.removePendingApproval(setOf(savedPendingSession)) + } + .launchIn(scope) + } suspend fun pair( url: String, @@ -56,43 +76,20 @@ internal class WcPairSdkDelegate : WcSdkObserver { }.first() suspend fun approve( + pendingSessionForSave: WcPendingApprovalSessionDTO, sessionApprove: Wallet.Params.SessionApprove, - ): Either = coroutineScope { - val approveCallback = async { withTimeout(CALLBACK_TIMEOUT.seconds) { approveCallback() } } - val approveCall = async { sdkApprove(sessionApprove) } - approveCall.await() - .onLeft { - approveCallback.cancel() - return@coroutineScope it.left() - } - return@coroutineScope approveCallback.await().fold( - ifLeft = { it.left() }, - ifRight = { result -> - when (result) { - is Wallet.Model.SettledSessionResponse.Result -> result.right() - is Wallet.Model.SettledSessionResponse.Error -> ApprovalFailed(result.errorMessage).left() - } + ): Either = coroutineScope { + val forSave = setOf(pendingSessionForSave) + store.savePendingApproval(forSave) + sdkApprove(sessionApprove).fold( + ifRight = { Unit.right() }, + ifLeft = { + store.removePendingApproval(forSave) + it.left() }, ) } - private suspend fun approveCallback() = callbackFlow> { - // wait first onSessionSettleResponse callback - launch { - val settledSessionResponse = onSessionSettleResponse.receiveAsFlow().first() - trySend(settledSessionResponse.right()) - channel.close() - } - // OR - // wait first onError callback - launch { - val error = onSdkErrorCallback.receiveAsFlow().first() - trySend(error.throwable.toApproveError()) - channel.close() - } - awaitClose() - }.first() - fun rejectSession(proposerPublicKey: String) { Timber.tag(WC_TAG).i("reject session proposerPublicKey = $proposerPublicKey") WalletKit.rejectSession( @@ -120,7 +117,7 @@ internal class WcPairSdkDelegate : WcSdkObserver { override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { // Triggered when wallet receives the session settlement response from Dapp - onSessionSettleResponse.trySend(settleSessionResponse) + onSessionSettleCallback.trySend(settleSessionResponse) } private suspend fun sdkApprove(sessionApprove: Wallet.Params.SessionApprove): Either { diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt index 8697778af1..6786e8a318 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -6,10 +6,7 @@ import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG -import com.tangem.data.walletconnect.utils.WcNetworksConverter -import com.tangem.data.walletconnect.utils.WcSdkObserver -import com.tangem.data.walletconnect.utils.WcSdkSessionConverter +import com.tangem.data.walletconnect.utils.* import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.walletconnect.WcAnalyticEvents @@ -18,10 +15,12 @@ import com.tangem.domain.walletconnect.model.WcSessionDTO import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* -import org.joda.time.DateTime +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.coroutines.resume @@ -32,7 +31,7 @@ internal class DefaultWcSessionsManager( private val dispatchers: CoroutineDispatcherProvider, private val wcNetworksConverter: WcNetworksConverter, private val analytics: AnalyticsEventHandler, - private val scope: CoroutineScope, + private val scope: WcScope, ) : WcSessionsManager, WcSdkObserver { private val onSessionDelete = Channel(capacity = Channel.BUFFERED) @@ -55,18 +54,6 @@ internal class DefaultWcSessionsManager( extendSessions() } - override suspend fun saveSession(session: WcSession) { - store.saveSession( - WcSessionDTO( - topic = session.sdkModel.topic, - walletId = session.wallet.walletId, - url = session.sdkModel.appMetaData.url, - securityStatus = session.securityStatus, - connectingTime = session.connectingTime ?: DateTime.now().millis, - ), - ) - } - override suspend fun removeSession(session: WcSession): Either { val topic = session.sdkModel.topic val sdkCall = sdkDisconnectSession(topic) @@ -91,7 +78,16 @@ internal class DefaultWcSessionsManager( inStore: Set, wallets: List, ): List { - val wcSessions = inStore.mapNotNull { storeSession -> + // if the WcSdk `onSessionSettleResponse` callback arrives late, merge pending approvals with WcSdk sessions + val savedPending = store.pendingApproval.first() + .mapNotNullTo(mutableSetOf()) { savedPendingSession -> + val sdkSession = inSdk + .find { sdkSession -> sdkSession.pairingTopic == savedPendingSession.pairingTopic } + ?: return@mapNotNullTo null + savedPendingSession.session.copy(topic = sdkSession.topic) + } + + val wcSessions = savedPending.plus(inStore).mapNotNull { storeSession -> val wallet = wallets.find { it.walletId == storeSession.walletId } ?: return@mapNotNull null val sdkSession = inSdk.find { it.topic == storeSession.topic } ?: return@mapNotNull null val networks = wcNetworksConverter.findWalletNetworks(wallet, sdkSession) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt new file mode 100644 index 0000000000..d394ce320d --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcScope.kt @@ -0,0 +1,13 @@ +package com.tangem.data.walletconnect.utils + +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +internal class WcScope( + dispatchers: CoroutineDispatcherProvider, +) : CoroutineScope { + + override val coroutineContext: CoroutineContext = SupervisorJob() + dispatchers.io +} \ No newline at end of file diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt index 6058a8f797..60a4fe2b99 100644 --- a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -16,11 +16,7 @@ import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.utils.WcSdkSessionConverter import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletconnect.model.WcPairError -import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.domain.walletconnect.model.WcSession -import com.tangem.domain.walletconnect.model.WcSessionApprove -import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.model.* import com.tangem.domain.walletconnect.usecase.pair.WcPairState import io.mockk.coEvery import io.mockk.coVerifyOrder @@ -32,7 +28,6 @@ import org.junit.Test internal class DefaultWcPairUseCaseTest { - private val sessionsManager: WcSessionsManager = mockk() private val associateNetworksDelegate: AssociateNetworksDelegate = mockk() private val caipNamespaceDelegate: CaipNamespaceDelegate = mockk() private val analytics: AnalyticsEventHandler = mockk(relaxed = true) @@ -83,11 +78,6 @@ internal class DefaultWcPairUseCaseTest { namespaces = mapOf(), ) - private val sdkApproveSuccess: Wallet.Model.SettledSessionResponse.Result - get() = Wallet.Model.SettledSessionResponse.Result( - session = sdkSession, - ) - private val sdkSession: Wallet.Model.Session get() = Wallet.Model.Session( pairingTopic = "", @@ -115,7 +105,6 @@ internal class DefaultWcPairUseCaseTest { ) private fun useCaseFactory() = DefaultWcPairUseCase( - sessionsManager = sessionsManager, associateNetworksDelegate = associateNetworksDelegate, caipNamespaceDelegate = caipNamespaceDelegate, sdkDelegate = sdkDelegate, @@ -155,12 +144,11 @@ internal class DefaultWcPairUseCaseTest { @Test fun `success pair and approve flow`() = runTest { val approveLoading = WcPairState.Approving.Loading(sessionForApprove) - val sessionForSave = sdkSession.sessionForSave - val result = WcPairState.Approving.Result(sessionForApprove, sessionForSave.right()) + val appMetaData = sdkSession.sessionForSave.sdkModel.appMetaData + val result = WcPairState.Approving.Result(sessionForApprove, appMetaData.right()) coEvery { sdkDelegate.pair(url) } returns (sdkProposal to sdkVerifyContext).right() - coEvery { sdkDelegate.approve(sdkApprove) } returns sdkApproveSuccess.right() - coEvery { sessionsManager.saveSession(any()) } returns Unit + coEvery { sdkDelegate.approve(any(), any()) } returns Unit.right() coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } val useCase = useCaseFactory() @@ -177,8 +165,7 @@ internal class DefaultWcPairUseCaseTest { assertEquals(approveLoading, awaitItem()) coVerifyOrder { - sdkDelegate.approve(sdkApprove) - sessionsManager.saveSession(any()) + sdkDelegate.approve(any(), any()) } val actual: WcPairState = awaitItem() assert(actual is WcPairState.Approving.Result) @@ -250,7 +237,7 @@ internal class DefaultWcPairUseCaseTest { val approveLoading = WcPairState.Approving.Loading(sessionForApprove) val error = WcPairError.ApprovalFailed("error").left() coEvery { sdkDelegate.pair(url) } returns (sdkProposal to sdkVerifyContext).right() - coEvery { sdkDelegate.approve(sdkApprove) } returns error + coEvery { sdkDelegate.approve(any(), any()) } returns error coEvery { sdkDelegate.rejectSession(sdkApprove.proposerPublicKey) } returns Unit coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } @@ -269,7 +256,7 @@ internal class DefaultWcPairUseCaseTest { assertEquals(approveLoading, awaitItem()) coVerifyOrder { - sdkDelegate.approve(sdkApprove) + sdkDelegate.approve(any(), any()) sdkDelegate.rejectSession(sdkApprove.proposerPublicKey) } assertEquals(errorResult, awaitItem()) diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt index 572d3aeb9a..15ccc11fc1 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt @@ -11,4 +11,15 @@ data class WcSessionDTO( val url: String?, val securityStatus: CheckDAppResult = CheckDAppResult.FAILED_TO_VERIFY, val connectingTime: Long? = null, +) + +/** + * keep in mind that [WcSessionDTO.topic] will be empty + * you must associate it with SdkSession by [pairingTopic] + */ +@JsonClass(generateAdapter = true) +data class WcPendingApprovalSessionDTO( + val pairingTopic: String, + val session: WcSessionDTO, + val expiredTime: Long, ) \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt index c2c2af4914..d74c2e9960 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.Flow interface WcSessionsManager { val sessions: Flow>> - suspend fun saveSession(session: WcSession) suspend fun removeSession(session: WcSession): Either suspend fun findSessionByTopic(topic: String): WcSession? } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt index 925877b063..4447132e96 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/BlockAidTransactionCheck.kt @@ -1,7 +1,6 @@ package com.tangem.domain.walletconnect.usecase.method import com.domain.blockaid.models.transaction.CheckTransactionResult -import com.domain.blockaid.models.transaction.simultation.TokenInfo import com.tangem.domain.core.lce.LceFlow interface BlockAidTransactionCheck { @@ -13,14 +12,6 @@ interface BlockAidTransactionCheck { data class Plain(override val result: CheckTransactionResult) : Result - data class Approval( - override val result: CheckTransactionResult, - val approval: WcApproval, - val tokenInfo: TokenInfo, - val isMutable: Boolean, - ) : Result { - - suspend fun approvalAmount() = approval.getAmount() - } + data class Approval(override val result: CheckTransactionResult) : Result } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt index 1097cfc7a5..a306af513f 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.walletconnect.usecase.pair import arrow.core.Either import com.tangem.domain.walletconnect.model.* +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import kotlinx.coroutines.flow.Flow interface WcPairUseCase { @@ -27,7 +28,7 @@ sealed interface WcPairState { data class Loading(override val session: WcSessionApprove) : Approving data class Result( override val session: WcSessionApprove, - val result: Either, + val result: Either, ) : Approving } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index 7449212ae2..bf416fe8f2 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -23,6 +23,7 @@ import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.* +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import com.tangem.domain.walletconnect.usecase.pair.WcPairState import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -96,7 +97,8 @@ internal class WcPairModel @Inject constructor( appInfoUiState.transformerUpdate( WcConnectButtonProgressTransformer(showProgress = false), ) - pairState.result + pairState + .result .onLeft(::processError) .onRight(::processSuccessfullyConnected) router.pop() @@ -203,12 +205,12 @@ internal class WcPairModel @Inject constructor( stackNavigation.pushNew(WcAppInfoRoutes.Alert.Verified(appName)) } - private fun processSuccessfullyConnected(session: WcSession) { + private fun processSuccessfullyConnected(session: WcAppMetaData) { messageSender.send( SnackbarMessage( message = resourceReference( id = R.string.wc_connected_to, - formatArgs = wrappedList(session.sdkModel.appMetaData.name), + formatArgs = wrappedList(session.name), ), ), ) From 00d14437a2b82f33273aad2da16bb01274afbc9a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Sep 2025 16:31:05 +0300 Subject: [PATCH 104/132] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 9b95133084..a256ae0146 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1225" +tangemBlockchainSdk = "develop-1228" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-561" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 9ebc1ac551a4697a41453c21d09f1907aa08d7fe Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Sep 2025 21:10:37 +0700 Subject: [PATCH 105/132] Updated on 2026-08-14 --- .../tap/di/domain/AccountDomainModule.kt | 6 ++ .../archived/ArchivedAccountListModel.kt | 70 ++++++++++++++++--- .../archived/entity/AccountArchivedUM.kt | 3 +- .../entity/AccountArchivedUMBuilder.kt | 55 +++++++++++++++ .../archived/ui/ArchivedAccountListContent.kt | 17 +++-- .../createedit/AccountCreateEditModel.kt | 33 ++++++++- .../createedit/entity/AccountCreateEditUM.kt | 1 + .../entity/AccountCreateEditUMBuilder.kt | 5 ++ .../createedit/ui/AccountCreateEditContent.kt | 3 + .../account/details/AccountDetailsModel.kt | 18 ++++- .../details/entity/AccountDetailsUM.kt | 12 +++- .../details/ui/AccountDetailsContent.kt | 32 +++++---- 12 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt index c4036dd181..d90cfcd00a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt @@ -43,6 +43,12 @@ internal object AccountDomainModule { return RecoverCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository) } + @Provides + @Singleton + fun provideGetArchivedAccountsUseCase(accountsCRUDRepository: AccountsCRUDRepository): GetArchivedAccountsUseCase { + return GetArchivedAccountsUseCase(crudRepository = accountsCRUDRepository) + } + @Provides @Singleton fun provideGetUnoccupiedAccountIndexUseCase( diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index f170b6653b..f1c4734092 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -10,34 +10,78 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.account.usecase.ArchivedAccountList +import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase -import com.tangem.domain.models.account.Account +import com.tangem.domain.core.lce.Lce import com.tangem.domain.models.account.AccountId import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.archived.entity.AccountArchivedUM +import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject -@Suppress("UnusedPrivateMember") // todo account +@Suppress("LongParameterList") internal class ArchivedAccountListModel @Inject constructor( paramsContainer: ParamsContainer, private val messageSender: UiMessageSender, private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val recoverCryptoPortfolioUseCase: RecoverCryptoPortfolioUseCase, + private val getArchivedAccountsUseCase: GetArchivedAccountsUseCase, + private val umBuilder: AccountArchivedUMBuilder, ) : Model() { private val params = paramsContainer.require() + private val onCloseClick = { router.pop() } val uiState: StateFlow get() = _uiState private val _uiState: MutableStateFlow = MutableStateFlow(getInitialState()) + private var getArchivedAccountsJob = JobHolder() - private fun confirmRecoverDialog(accountId: AccountId) { - val account: Account? = null // todo account find - account ?: return + init { + getArchivedAccounts() + } + + private fun getArchivedAccounts() { + getArchivedAccountsUseCase(params.userWalletId) + .conflate() + .distinctUntilChanged() + .onEach { lce -> + val newState = when (lce) { + is Lce.Content -> umBuilder.mapContent( + accounts = lce.content, + onCloseClick = onCloseClick, + confirmRecoverDialog = { confirmRecoverDialog(it) }, + ) + is Lce.Error -> umBuilder.mapError( + throwable = lce.error, + onCloseClick = onCloseClick, + getArchivedAccounts = { getArchivedAccounts() }, + ) + is Lce.Loading -> lce.partialContent?.let { content -> + umBuilder.mapContent( + accounts = content, + onCloseClick = onCloseClick, + confirmRecoverDialog = { confirmRecoverDialog(it) }, + ) + } + } + newState?.let { _uiState.value = newState } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + .saveIn(getArchivedAccountsJob) + } + + private fun confirmRecoverDialog(account: ArchivedAccount) { val secondAction = EventMessageAction( title = resourceReference(R.string.common_cancel), onClick = {}, @@ -51,7 +95,7 @@ internal class ArchivedAccountListModel @Inject constructor( title = resourceReference(R.string.account_archived_recover_dialog_title), message = resourceReference( id = R.string.account_archived_recover_dialog_description, - formatArgs = wrappedList(account.accountName.toUM().value), + formatArgs = wrappedList(account.name.toUM().value), ), firstActionBuilder = { firstAction }, secondActionBuilder = { secondAction }, @@ -61,11 +105,19 @@ internal class ArchivedAccountListModel @Inject constructor( private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch { recoverCryptoPortfolioUseCase(accountId) + .onLeft { Timber.e(it.toString()) } + .onRight { showSuccessRecoverMessage() } + router.pop() + } + + private fun showSuccessRecoverMessage() { + val message = resourceReference(R.string.account_recover_success_message) + messageSender.send(ToastMessage(message = message)) } private fun getInitialState(): AccountArchivedUM { return AccountArchivedUM.Loading( - onCloseClick = { router.pop() }, + onCloseClick = onCloseClick, ) } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt index bd72960062..a29481e093 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt @@ -23,5 +23,6 @@ internal data class ArchivedAccountUM( val accountName: TextReference, val accountIconUM: CryptoPortfolioIconUM, val tokensInfo: TextReference, - val onClick: (accountId: String) -> Unit, + val networksInfo: TextReference, + val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt new file mode 100644 index 0000000000..1b7b801cf3 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt @@ -0,0 +1,55 @@ +package com.tangem.features.account.archived.entity + +import com.tangem.common.ui.account.toUM +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.account.usecase.ArchivedAccountList +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber +import javax.inject.Inject + +internal class AccountArchivedUMBuilder @Inject constructor() { + + fun mapContent( + accounts: ArchivedAccountList, + onCloseClick: () -> Unit, + confirmRecoverDialog: (account: ArchivedAccount) -> Unit, + ) = AccountArchivedUM.Content( + onCloseClick = onCloseClick, + accounts = accounts + .map { account -> account.mapArchivedAccountUM(confirmRecoverDialog) } + .toImmutableList(), + ) + + fun ArchivedAccount.mapArchivedAccountUM(confirmRecoverDialog: (account: ArchivedAccount) -> Unit) = + ArchivedAccountUM( + accountId = accountId.value, + accountName = name.toUM().value, + accountIconUM = icon.toUM(), + tokensInfo = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + networksInfo = pluralReference( + R.plurals.common_networks_count, + count = networksCount, + formatArgs = wrappedList(networksCount), + ), + onClick = { confirmRecoverDialog(this) }, + ) + + fun mapError( + throwable: Throwable, + onCloseClick: () -> Unit, + getArchivedAccounts: () -> Unit, + ): AccountArchivedUM.Error { + Timber.e(throwable) + return AccountArchivedUM.Error( + onCloseClick = onCloseClick, + onRetryClick = { getArchivedAccounts() }, + ) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt index a563f952d3..fca08ec492 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt @@ -21,6 +21,7 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -125,14 +126,21 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod Row( modifier = modifier .fillMaxWidth() - .clickable(onClick = { item.onClick(item.accountId) }) + .clickable(onClick = item.onClick) .padding(all = TangemTheme.dimens.spacing12), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { + val subtitle = stringResourceSafe( + id = R.string.account_label_tokens_info, + formatArgs = arrayOf( + item.tokensInfo.resolveReference(), + item.networksInfo.resolveReference(), + ), + ) AccountRow( title = item.accountName, - subtitle = item.tokensInfo, + subtitle = stringReference(subtitle), icon = item.accountIconUM, modifier = Modifier.weight(1f), ) @@ -140,7 +148,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod SecondarySmallButton( config = SmallButtonConfig( text = resourceReference(R.string.account_archived_recover), - onClick = { item.onClick(item.accountId) }, + onClick = item.onClick, ), ) } @@ -166,7 +174,8 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider Unit, val text: TextReference, ) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt index 87947361a4..3b710ed06d 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt @@ -77,6 +77,7 @@ internal class AccountCreateEditUMBuilder( } return AccountCreateEditUM.Button( isButtonEnabled = false, + showProgress = false, onConfirmClick = onConfirmClick, text = text, ) @@ -117,6 +118,10 @@ internal class AccountCreateEditUMBuilder( return this.copy(buttonState = this.buttonState.copy(isButtonEnabled = isButtonEnabled)) } + fun AccountCreateEditUM.toggleProgress(showProgress: Boolean): AccountCreateEditUM { + return this.copy(buttonState = this.buttonState.copy(showProgress = showProgress)) + } + fun AccountCreateEditUM.updateDerivationIndex(derivationIndex: Int): AccountCreateEditUM { return this.copy( account = this.account.copy( diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt index 2b7635a7df..a1f8f283a7 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt @@ -80,6 +80,7 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi .fillMaxWidth() .padding(16.dp), enabled = state.buttonState.isButtonEnabled, + showProgress = state.buttonState.showProgress, text = state.buttonState.text.resolveReference(), onClick = state.buttonState.onConfirmClick, ) @@ -309,6 +310,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider when (account.isMainAccount) { + true -> AccountDetailsUM.ArchiveMode.None + false -> AccountDetailsUM.ArchiveMode.Available( + onArchiveAccountClick = ::onArchiveAccountClick, + ) + } + } return AccountDetailsUM( accountName = params.account.accountName.toUM().value, accountIcon = params.account.portfolioIcon.toUM(), onCloseClick = { router.pop() }, onAccountEditClick = ::onEditAccountClick, onManageTokensClick = ::onManageTokensClick, - onArchiveAccountClick = ::onArchiveAccountClick, + archiveMode = archiveMode, ) } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt index 6d1bb241b8..3938e757fb 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt @@ -6,8 +6,16 @@ import com.tangem.core.ui.extensions.TextReference internal data class AccountDetailsUM( val accountName: TextReference, val accountIcon: CryptoPortfolioIconUM, + val archiveMode: ArchiveMode, val onCloseClick: () -> Unit, val onAccountEditClick: () -> Unit, val onManageTokensClick: () -> Unit, - val onArchiveAccountClick: () -> Unit, -) \ No newline at end of file +) { + + sealed interface ArchiveMode { + data object None : ArchiveMode + data class Available( + val onArchiveAccountClick: () -> Unit, + ) : ArchiveMode + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt index 510f776f53..bd4a0e319f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -65,21 +65,26 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = AccountRow(state) SpacerH16() ManageTokensRow(state) - SpacerH16() - ArchiveAccountRow(state) - SpacerH(8.dp) - Text( - modifier = Modifier.padding(horizontal = 12.dp), - text = stringResourceSafe(R.string.account_details_archive_description), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) + when (state.archiveMode) { + is AccountDetailsUM.ArchiveMode.Available -> { + SpacerH16() + ArchiveAccountRow(state.archiveMode) + SpacerH(8.dp) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResourceSafe(R.string.account_details_archive_description), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + AccountDetailsUM.ArchiveMode.None -> Unit + } } } } @Composable -private fun ArchiveAccountRow(state: AccountDetailsUM) { +private fun ArchiveAccountRow(state: AccountDetailsUM.ArchiveMode.Available) { Row( modifier = Modifier .fillMaxWidth() @@ -176,12 +181,15 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider Date: Mon, 15 Sep 2025 19:30:17 +0500 Subject: [PATCH 106/132] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../tap/di/domain/YieldSupplyDomainModule.kt | 50 +++ data/transaction/build.gradle.kts | 17 +- .../data/transaction/DefaultFeeRepository.kt | 46 ++- .../transaction/di/TransactionDataModule.kt | 16 +- .../error/DefaultFeeErrorResolver.kt | 22 ++ data/yield-supply/build.gradle.kts | 48 +++ ...DefaultYieldSupplyTransactionRepository.kt | 371 ++++++++++++++++++ .../yield/supply/di/YieldSupplyDataModule.kt | 28 ++ ...ultYieldSupplyTransactionRepositoryTest.kt | 288 ++++++++++++++ .../tangem/domain/utils/BigDecimalUtils.kt | 32 +- .../domain/transaction/FeeRepository.kt | 11 + .../transaction/error/FeeErrorResolver.kt | 6 + domain/yield-supply/build.gradle.kts | 35 ++ .../YieldSupplyTransactionRepository.kt | 23 ++ .../YieldSupplyEstimateEnterFeeUseCase.kt | 58 +++ .../usecase/YieldSupplyStartEarningUseCase.kt | 22 ++ .../usecase/YieldSupplyStopEarningUseCase.kt | 29 ++ .../YieldSupplyEstimateEnterFeeUseCaseTest.kt | 172 ++++++++ settings.gradle.kts | 2 + 20 files changed, 1261 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt create mode 100644 data/yield-supply/build.gradle.kts create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt create mode 100644 domain/yield-supply/build.gradle.kts create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 523778d665..cfb020cedd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,6 +153,7 @@ dependencies { implementation(projects.domain.swap) implementation(projects.domain.walletManager) implementation(projects.domain.walletManager.models) + implementation(projects.domain.yieldSupply) implementation(projects.common) implementation(projects.common.routing) @@ -201,6 +202,7 @@ dependencies { implementation(projects.data.notifications) implementation(projects.data.swap) implementation(projects.data.walletManager) + implementation(projects.data.yieldSupply) /** Features */ implementation(projects.features.referral.impl) diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt new file mode 100644 index 0000000000..1b342eb608 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -0,0 +1,50 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object YieldSupplyDomainModule { + + @Provides + @Singleton + fun provideYieldSupplyStartEarningUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): YieldSupplyStartEarningUseCase { + return YieldSupplyStartEarningUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyStopEarningUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): YieldSupplyStopEarningUseCase { + return YieldSupplyStopEarningUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyEstimateEnterFeeUseCase( + feeRepository: FeeRepository, + feeErrorResolver: FeeErrorResolver, + ): YieldSupplyEstimateEnterFeeUseCase { + return YieldSupplyEstimateEnterFeeUseCase( + feeRepository = feeRepository, + feeErrorResolver = feeErrorResolver, + ) + } +} \ No newline at end of file diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index 47cc934a90..fa74ec6c43 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -10,6 +10,10 @@ android { namespace = "com.tangem.data.transaction" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Tangem SDKs */ @@ -21,13 +25,14 @@ dependencies { implementation(projects.core.utils) /** Domain */ - implementation(projects.domain.transaction) + implementation(projects.libs.blockchainSdk) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) - implementation(projects.libs.blockchainSdk) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.demo) /** DI */ implementation(deps.hilt.android) @@ -35,4 +40,12 @@ dependencies { /** Other */ implementation(deps.timber) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt index b018bc23c3..49b6e3c486 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt @@ -1,13 +1,57 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.walletmanager.WalletManagersFacade -internal class DefaultFeeRepository : FeeRepository { +internal class DefaultFeeRepository( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, +) : FeeRepository { override fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean { return networkId.toBlockchain().isFeeApproximate(amountType) } + + override suspend fun calculateFee( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionData: TransactionData, + ): TransactionFee { + val transactionSender = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, cryptoCurrency) + } else { + walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) ?: error("WalletManager is null") + } + + return when (val result = transactionSender.getFee(transactionData)) { + is Result.Success -> result.data + is Result.Failure -> throw result.error + } + } + + private suspend fun demoTransactionSender( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) + ?: error("WalletManager is null"), + ) + } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index 6184d3fa48..267e449c57 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -3,10 +3,13 @@ package com.tangem.data.transaction.di import com.tangem.data.transaction.DefaultFeeRepository import com.tangem.data.transaction.DefaultTransactionRepository import com.tangem.data.transaction.DefaultWalletAddressServiceRepository +import com.tangem.data.transaction.error.DefaultFeeErrorResolver import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository +import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -35,8 +38,11 @@ internal object TransactionDataModule { @Provides @Singleton - fun providesFeeRepository(): FeeRepository { - return DefaultFeeRepository() + fun providesFeeRepository(walletManagersFacade: WalletManagersFacade): FeeRepository { + return DefaultFeeRepository( + walletManagersFacade, + demoConfig = DemoConfig(), + ) } @Provides @@ -50,4 +56,10 @@ internal object TransactionDataModule { dispatchers = coroutineDispatcherProvider, ) } + + @Provides + @Singleton + fun providerFeeErrorResolver(): FeeErrorResolver { + return DefaultFeeErrorResolver() + } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt new file mode 100644 index 0000000000..b1469917d7 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt @@ -0,0 +1,22 @@ +package com.tangem.data.transaction.error + +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.transaction.error.GetFeeError + +internal class DefaultFeeErrorResolver : FeeErrorResolver { + override fun resolve(throwable: Throwable): GetFeeError { + return when (throwable) { + is BlockchainSdkError.Tron.AccountActivationError -> { + GetFeeError.BlockchainErrors.TronActivationError + } + is BlockchainSdkError.Kaspa.ZeroUtxoError -> { + GetFeeError.BlockchainErrors.KaspaZeroUtxo + } + is BlockchainSdkError.Sui.OneSuiRequired -> { + GetFeeError.BlockchainErrors.SuiOneCoinRequired + } + else -> GetFeeError.DataError(throwable) + } + } +} \ No newline at end of file diff --git a/data/yield-supply/build.gradle.kts b/data/yield-supply/build.gradle.kts new file mode 100644 index 0000000000..b0cc1f2844 --- /dev/null +++ b/data/yield-supply/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.data.yield.supply" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + + /** Tangem SDKs */ + implementation(tangemDeps.blockchain) + + /** Core */ + implementation(projects.core.datasource) + implementation(projects.core.utils) + + /** Domain */ + implementation(projects.domain.yieldSupply) + implementation(projects.domain.walletManager) + implementation(projects.domain.legacy) + + implementation(projects.libs.blockchainSdk) + + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.timber) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt new file mode 100644 index 0000000000..86c802d7db --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -0,0 +1,371 @@ +package com.tangem.data.yield.supply + +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.blockchains.ethereum.tokenmethods.ApprovalERC20TokenCallData +import com.tangem.blockchain.blockchains.tron.TronTransactionExtras +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.math.BigDecimal + +@Suppress("LargeClass") +internal class DefaultYieldSupplyTransactionRepository( + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldSupplyTransactionRepository { + + override suspend fun createEnterTransactions( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List { + val cryptoCurrency = cryptoCurrencyStatus.currency + + require(cryptoCurrency is CryptoCurrency.Token) + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + val existingYieldContractAddress = getYieldContractAddress( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + + val calculatedYieldContractAddress = calculateYieldContractAddress( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) ?: error("Calculated yield contract address is null") + + val yieldTokenStatus = cryptoCurrencyStatus.value.yieldSupplyStatus ?: getYieldTokenStatus( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + ) + + return buildEnterTransactions( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + existingYieldContractAddress = existingYieldContractAddress, + calculatedYieldContractAddress = calculatedYieldContractAddress, + yieldTokenStatus = yieldTokenStatus, + ) + } + + override suspend fun createExitTransaction( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + yieldSupplyStatus: YieldSupplyStatus, + fee: Fee?, + ): TransactionData.Uncompiled = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + val callData = YieldSupplyContractCallDataProviderFactory.getExitCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + ) + + createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = walletManager.getYieldContract(), + yieldSupplyStatus = yieldSupplyStatus, + fee = fee, + ) + } + + private fun buildEnterTransactions( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + existingYieldContractAddress: String?, + calculatedYieldContractAddress: String, + yieldTokenStatus: YieldSupplyStatus?, + ): MutableList { + val enterTransactions = mutableListOf() + + when { + existingYieldContractAddress == null || existingYieldContractAddress == EthereumUtils.ZERO_ADDRESS -> { + enterTransactions.add( + createDeployTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + ), + ) + } + yieldTokenStatus == null -> error("Yield token status is null") + !yieldTokenStatus.isInitialized -> enterTransactions.add( + createInitTokenTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + !yieldTokenStatus.isActive -> enterTransactions.add( + createReactivateTokenTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + else -> Unit + } + + if (yieldTokenStatus?.isAllowedToSpend == false) { + enterTransactions.add( + createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = ApprovalERC20TokenCallData( + spenderAddress = calculatedYieldContractAddress, + amount = null, + ), + destinationAddress = cryptoCurrency.contractAddress, + yieldSupplyStatus = yieldTokenStatus, + fee = null, + ), + ) + } + + enterTransactions.add( + createEnterTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + + return enterTransactions + } + + private suspend fun calculateYieldContractAddress( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): String? = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + walletManager.calculateYieldContract() + }.onFailure(Timber::e) + .getOrNull() + } + + private suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? = + withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + walletManager.getYieldContract() + }.onFailure(Timber::e) + .getOrNull() + } + + private suspend fun getYieldTokenStatus( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency, + ): YieldSupplyStatus? = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val sdkSupplyStatus = walletManager.getYieldSupplyStatus(cryptoCurrency.contractAddress) + val isAllowedToSpend = walletManager.isAllowedToSpend(cryptoCurrency.contractAddress) + + YieldSupplyStatus( + isActive = sdkSupplyStatus?.isActive == true, + isInitialized = sdkSupplyStatus?.isInitialized == true, + isAllowedToSpend = isAllowedToSpend, + ) + }.onFailure(Timber::e).getOrNull() + } + + private fun createDeployTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getDeployCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + walletAddress = walletManager.wallet.address, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + val factoryContractAddress = walletManager.getYieldSupplyContractAddresses()?.factoryContractAddress + ?: error("Factory contract address is null") + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = factoryContractAddress, + yieldSupplyStatus = null, + fee = null, + ) + } + + private fun createInitTokenTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldContractAddress: String, + yieldSupplyStatus: YieldSupplyStatus, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + private fun createReactivateTokenTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldContractAddress: String, + yieldSupplyStatus: YieldSupplyStatus, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + private fun createEnterTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldSupplyStatus: YieldSupplyStatus?, + yieldContractAddress: String, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getEnterCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + @Suppress("LongParameterList") + private fun createTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency, + callData: SmartContractCallData, + destinationAddress: String, + yieldSupplyStatus: YieldSupplyStatus?, + fee: Fee?, + ): TransactionData.Uncompiled { + requireNotNull(cryptoCurrency as? CryptoCurrency.Token) + val blockchain = cryptoCurrency.network.id.toBlockchain() + + val extras = createTransactionDataExtras( + callData = callData, + blockchain = blockchain, + ) + + val amount = getYieldSupplyAmount(cryptoCurrency, yieldSupplyStatus) + + return if (fee != null) { + walletManager.createTransaction( + amount = amount, + fee = fee, + destination = destinationAddress, + ).copy( + extras = extras, + ) + } else { + TransactionData.Uncompiled( + amount = amount, + sourceAddress = walletManager.wallet.address, + destinationAddress = destinationAddress, + extras = extras, + fee = null, + ) + } + } + + private fun createTransactionDataExtras( + callData: SmartContractCallData, + blockchain: Blockchain, + ): TransactionExtras { + return when { + blockchain.isEvm() -> { + EthereumTransactionExtras( + callData = callData, + gasLimit = null, + nonce = null, + ) + } + blockchain == Blockchain.Tron -> { + TronTransactionExtras( + callData = callData, + ) + } + else -> error("Data extras not supported for $blockchain") + } + } + + private fun getYieldSupplyAmount(cryptoCurrency: CryptoCurrency.Token, yieldSupplyStatus: YieldSupplyStatus?) = + BigDecimal.ZERO.convertToSdkAmount( + cryptoCurrency = cryptoCurrency, + amountType = AmountType.TokenYieldSupply( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + isActive = yieldSupplyStatus?.isActive ?: false, + isInitialized = yieldSupplyStatus?.isInitialized ?: false, + isAllowedToSpend = yieldSupplyStatus?.isAllowedToSpend ?: false, + ), + ) + + private companion object { + val MAX_NETWORK_FEE: BigDecimal = BigDecimal.TEN // TODO for TESTNET only + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt new file mode 100644 index 0000000000..2b2f45f06c --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.yield.supply.di + +import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object YieldSupplyDataModule { + + @Provides + @Singleton + fun providerYieldSupplyTransactionRepository( + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): YieldSupplyTransactionRepository { + return DefaultYieldSupplyTransactionRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt new file mode 100644 index 0000000000..069e3630f8 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -0,0 +1,288 @@ +package com.tangem.data.yield.supply + +import com.google.common.truth.Truth +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory +import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.blockchain.yieldsupply.providers.YieldSupplyStatus as SDKYieldSupplyStatus +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultYieldSupplyTransactionRepositoryTest { + + private val networkId = Network.ID(value = "ETH/test", derivationPath = Network.DerivationPath.None) + private val mockedContractAddress = "0x000000000000000000000000000000000000" + private val yieldContractAddress = "0x1234" + + private val userWalletId = mockk() + private val cryptoCurrency = mockk(relaxed = true) { + every { network.id } returns networkId + every { contractAddress } returns mockedContractAddress + } + private val cryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns cryptoCurrency + every { value.yieldSupplyStatus } returns null + } + + private val walletManager = mockk(relaxed = true) { + every { wallet } returns mockk(relaxed = true) + every { getYieldSupplyContractAddresses() } returns mockk(relaxed = true) { + every { factoryContractAddress } returns "factory" + } + } + private val walletManagersFacade: WalletManagersFacade = mockk { + coEvery { getOrCreateWalletManager(any(), any(), any()) } returns walletManager + } + private lateinit var repository: DefaultYieldSupplyTransactionRepository + + @BeforeEach + fun setUp() { + repository = spyk( + objToCopy = DefaultYieldSupplyTransactionRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + ), + recordPrivateCalls = true, + ) + every { mockk().contractAddress } returns mockedContractAddress + } + + @Test + fun `createEnterTransactions returns deploy-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns EthereumUtils.ZERO_ADDRESS + coEvery { walletManager.getYieldSupplyStatus(any()) } returns null + coEvery { walletManager.isAllowedToSpend(any()) } returns false + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: deploy, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - deploy + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getDeployCallData( + walletAddress = walletManager.wallet.address, + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns init-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = false, + maxNetworkFee = BigDecimal.TEN, + ) + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: init token, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - init token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns reactivate-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: reactivate token, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - reactivate token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns reactivate-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + coEvery { walletManager.isAllowedToSpend(any()) } returns true + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 2 transactions are returned: approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - reactivate token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[1] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = true, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + coEvery { walletManager.isAllowedToSpend(any()) } returns true + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that transaction is returned: enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[0] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createExitTransaction returns valid transaction`() = runTest { + val expectedCallData = + YieldSupplyContractCallDataProviderFactory.getExitCallData(mockedContractAddress) + + val yieldSupplyStatus = mockk(relaxed = true) + + val result = repository.createExitTransaction(userWalletId, cryptoCurrency, yieldSupplyStatus, null) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data) + } +} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt index 1bfffa0dc3..677c7b354a 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt @@ -7,18 +7,26 @@ import java.math.BigDecimal import com.tangem.blockchain.common.Amount as SdkAmount /** Converts `BigDecimal` [cryptoCurrency] to [SdkAmount] */ -fun BigDecimal.convertToSdkAmount(cryptoCurrency: CryptoCurrency): SdkAmount = SdkAmount( +fun BigDecimal.convertToSdkAmount( + cryptoCurrency: CryptoCurrency, + amountType: AmountType = getAmountTypeFromCryptoCurrency(cryptoCurrency), +): SdkAmount = SdkAmount( currencySymbol = cryptoCurrency.symbol, value = this, decimals = cryptoCurrency.decimals, - type = when (cryptoCurrency) { - is CryptoCurrency.Coin -> AmountType.Coin - is CryptoCurrency.Token -> AmountType.Token( - token = Token( - symbol = cryptoCurrency.symbol, - contractAddress = cryptoCurrency.contractAddress, - decimals = cryptoCurrency.decimals, - ), - ) - }, -) \ No newline at end of file + type = amountType, +) + +/** + * Converts [CryptoCurrency] to [AmountType] based on its type + */ +private fun getAmountTypeFromCryptoCurrency(cryptoCurrency: CryptoCurrency) = when (cryptoCurrency) { + is CryptoCurrency.Coin -> AmountType.Coin + is CryptoCurrency.Token -> AmountType.Token( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt index a5e7e04e36..f2b3a91661 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt @@ -1,10 +1,21 @@ package com.tangem.domain.transaction import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet interface FeeRepository { /** Returns if fee is approximate for current [networkId] */ fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean + + /** Returns fee calculated for the transaction [transactionData] */ + suspend fun calculateFee( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionData: TransactionData, + ): TransactionFee } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt new file mode 100644 index 0000000000..058cfa752a --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.transaction.error + +interface FeeErrorResolver { + + fun resolve(throwable: Throwable): GetFeeError +} \ No newline at end of file diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts new file mode 100644 index 0000000000..e10c9dba42 --- /dev/null +++ b/domain/yield-supply/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.domain.yield.supply" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.legacy) + + /** Tandem SDK */ + implementation(tangemDeps.blockchain) + + /** Other */ + implementation(deps.arrow.core) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt new file mode 100644 index 0000000000..34a17c1905 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.yield.supply + +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus + +interface YieldSupplyTransactionRepository { + + suspend fun createEnterTransactions( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List + + suspend fun createExitTransaction( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + yieldSupplyStatus: YieldSupplyStatus, + fee: Fee?, + ): TransactionData.Uncompiled +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt new file mode 100644 index 0000000000..0c7dd8bd8c --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt @@ -0,0 +1,58 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.transaction.error.GetFeeError + +class YieldSupplyEstimateEnterFeeUseCase( + private val feeRepository: FeeRepository, + private val feeErrorResolver: FeeErrorResolver, +) { + suspend operator fun invoke( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionDataList: List, + ): Either> = Either.catch { + transactionDataList.mapIndexed { index, transaction -> + val fee = feeRepository.calculateFee( + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + transactionData = transaction, + ).normal + + if (index == transactionDataList.lastIndex) { // todo yield supply replace with check for contract + transaction.copy(fee = fee.fixFee(cryptoCurrency)) + } else { + transaction.copy(fee = fee) + } + } + }.mapLeft(feeErrorResolver::resolve) + + private fun Fee.fixFee(cryptoCurrency: CryptoCurrency) = when (this) { + is Fee.Ethereum.Legacy -> copy( + gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT, + amount = amount.copy( + value = gasPrice.multiply(ETHEREUM_CONSTANT_GAS_LIMIT) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + is Fee.Ethereum.EIP1559 -> copy( + gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT, + amount = amount.copy( + value = maxFeePerGas.multiply(ETHEREUM_CONSTANT_GAS_LIMIT) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + else -> this + } + + private companion object { + // Using constant gas limit to avoid fee calculation errors when contract address is not deployed yet + val ETHEREUM_CONSTANT_GAS_LIMIT = 350_000.toBigInteger() + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt new file mode 100644 index 0000000000..b5e73e680f --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository + +class YieldSupplyStartEarningUseCase( + private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either> = Either.catch { + yieldSupplyTransactionRepository.createEnterTransactions( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt new file mode 100644 index 0000000000..aab058196b --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository + +class YieldSupplyStopEarningUseCase( + private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + fee: Fee?, + ): Either = Either.catch { + val yieldTokenStatus = cryptoCurrencyStatus.value.yieldSupplyStatus ?: error("") + val cryptoCurrency = cryptoCurrencyStatus.currency + + yieldSupplyTransactionRepository.createExitTransaction( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + fee = null, + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt new file mode 100644 index 0000000000..a38b1d1f15 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt @@ -0,0 +1,172 @@ +package com.tangem.domain.yield.supply + +import arrow.core.Either +import com.google.common.truth.Truth +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class YieldSupplyEstimateEnterFeeUseCaseTest { + private val feeRepository: FeeRepository = mockk() + private val feeErrorResolver: FeeErrorResolver = mockk() + private val useCase = YieldSupplyEstimateEnterFeeUseCase(feeRepository, feeErrorResolver) + + private val userWallet: UserWallet = mockk() + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { decimals } returns 18 + } + + private fun ethLegacyFee( + gasPrice: BigInteger = BigInteger.valueOf(100_000_000_000L), + gasLimit: BigInteger = BigInteger.valueOf(21_000), + ) = Fee.Ethereum.Legacy( + gasPrice = gasPrice, + gasLimit = gasLimit, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + ) + + private fun ethEip1559Fee( + maxFeePerGas: BigInteger = BigInteger.valueOf(100_000_000_000L), + gasLimit: BigInteger = BigInteger.valueOf(21_000), + ) = Fee.Ethereum.EIP1559( + maxFeePerGas = maxFeePerGas, + priorityFee = BigInteger.ONE, + gasLimit = gasLimit, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + ) + + private fun uncompiled(fee: Fee) = TransactionData.Uncompiled( + fee = fee, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + contractAddress = null, + sourceAddress = "0x1234567890123456789012345678901234567890", + destinationAddress = "0x1234567890123456789012345678901234567890", + ) + + @Test + fun `test 1 transaction uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee + + val result = useCase(userWallet, cryptoCurrency, listOf(tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(1) + + val lastFee = txs.last().fee as Fee.Ethereum.Legacy + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 2 transactions, only last uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(2) + + val firstFee = txs.first().fee as Fee.Ethereum.Legacy + val lastFee = txs.last().fee as Fee.Ethereum.Legacy + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 3 transactions, only last uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(3) + + val firstFee = txs[0].fee as Fee.Ethereum.Legacy + val secondFee = txs[1].fee as Fee.Ethereum.Legacy + val lastFee = txs[2].fee as Fee.Ethereum.Legacy + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(secondFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 1 transaction uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee + + val result = useCase(userWallet, cryptoCurrency, listOf(tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(1) + + val lastFee = txs.last().fee as Fee.Ethereum.EIP1559 + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 2 transactions, only last uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(2) + + val firstFee = txs.first().fee as Fee.Ethereum.EIP1559 + val lastFee = txs.last().fee as Fee.Ethereum.EIP1559 + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 3 transactions, only last uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(3) + + val firstFee = txs[0].fee as Fee.Ethereum.EIP1559 + val secondFee = txs[1].fee as Fee.Ethereum.EIP1559 + val lastFee = txs[2].fee as Fee.Ethereum.EIP1559 + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(secondFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 305ea20cc8..365023aad8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -350,6 +350,7 @@ include(":domain:swap") include(":domain:swap:models") include(":domain:wallet-manager") include(":domain:wallet-manager:models") +include(":domain:yield-supply") // endregion Domain modules // region Data modules @@ -383,5 +384,6 @@ include(":data:blockaid") include(":data:swap") include(":data:express") include(":data:wallet-manager") +include(":data:yield-supply") // endregion Data modules include(":features:tangempay:onboarding") \ No newline at end of file From b42fa5283cde2ce6119f31193a05af538198c76b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Sep 2025 17:21:47 +0400 Subject: [PATCH 107/132] Updated on 2026-08-14 --- .../FetchWalletAccountsErrorHandler.kt | 32 ++++++------------- .../DefaultMultiNetworkStatusProducer.kt | 5 +-- .../DefaultSingleNetworkStatusProducer.kt | 6 ++-- .../DefaultSingleQuoteStatusProducer.kt | 12 +++++-- .../multi/DefaultMultiYieldBalanceProducer.kt | 5 +-- .../DefaultSingleYieldBalanceProducer.kt | 6 ++-- ...aultMultiWalletCryptoCurrenciesProducer.kt | 7 ++-- .../tangem/domain/core/flow/FlowProducer.kt | 5 +-- .../core/flow/FlowCachingSupplierTest.kt | 10 +++--- 9 files changed, 45 insertions(+), 43 deletions(-) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 520868e3f6..2acc302f24 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -18,7 +18,6 @@ import com.tangem.domain.account.models.AccountList import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.Provider import timber.log.Timber import javax.inject.Inject @@ -69,12 +68,13 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( return } - val userWalletProvider = Provider { userWalletsStore.getSyncStrict(key = userWalletId) } - val accountDTOs = savedAccountsResponse?.accounts.orDefault(userWalletProvider = userWalletProvider) + val (accountDTOs, userTokensResponse) = if (savedAccountsResponse == null) { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) - val userTokensResponse = savedAccountsResponse?.toUserTokensResponse() - .orFromLegacyStore(userWalletProvider = userWalletProvider) - .orDefault(userWalletProvider = userWalletProvider) + createDefaultAccountDTOs(userWallet) to getFromLegacyStore(userWalletId).orDefault(userWallet) + } else { + savedAccountsResponse.accounts to savedAccountsResponse.toUserTokensResponse() + } val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) if (isNotFoundError) { @@ -86,11 +86,7 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( storeWalletAccounts(userWalletId, response) } - private fun List?.orDefault(userWalletProvider: Provider): List { - if (this != null) return this - - val userWallet = userWalletProvider() - + private fun createDefaultAccountDTOs(userWallet: UserWallet): List { val accounts = AccountList.empty(userWallet).accounts .filterIsInstance() @@ -99,24 +95,16 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( return converter.convertListBack(input = accounts) } - private suspend fun UserTokensResponse?.orFromLegacyStore( - userWalletProvider: Provider, - ): UserTokensResponse? { - if (this != null) return this - - val userWalletId = userWalletProvider().walletId - + private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? { return userTokensResponseStore.getSyncOrNull(userWalletId) .also { userTokensResponseStore.clear(userWalletId) } } - private fun UserTokensResponse?.orDefault(userWalletProvider: Provider): UserTokensResponse { + private fun UserTokensResponse?.orDefault(userWallet: UserWallet): UserTokensResponse { if (this != null) return this return userTokensResponseFactory.createUserTokensResponse( - currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet( - userWallet = userWalletProvider(), - ), + currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet = userWallet), isGroupedByNetwork = false, isSortedByBalance = false, ) diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt index b4fc8494be..267f975e39 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.networks.multi +import arrow.core.Option +import arrow.core.some import com.tangem.data.common.network.NetworkFactory import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -29,8 +31,7 @@ internal class DefaultMultiNetworkStatusProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : MultiNetworkStatusProducer { - override val fallback: Set - get() = setOf() + override val fallback: Option> = emptySet().some() override fun produce(): Flow> { return networksStatusesStore.get(userWalletId = params.userWalletId) diff --git a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt index f2b61d8433..76cbeeb604 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.networks.single +import arrow.core.Option +import arrow.core.some import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier @@ -28,8 +30,8 @@ internal class DefaultSingleNetworkStatusProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : SingleNetworkStatusProducer { - override val fallback: NetworkStatus - get() = NetworkStatus(network = params.network, value = NetworkStatus.Unreachable(address = null)) + override val fallback: Option + get() = NetworkStatus(network = params.network, value = NetworkStatus.Unreachable(address = null)).some() override fun produce(): Flow { return multiNetworkStatusSupplier( diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducer.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducer.kt index 4adc0ed55a..c02e49b1d8 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducer.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.quotes.single +import arrow.core.Option +import arrow.core.some import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.quotes.single.SingleQuoteStatusProducer @@ -7,7 +9,10 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull /** * Default implementation of [SingleQuoteStatusProducer] @@ -21,11 +26,12 @@ internal class DefaultSingleQuoteStatusProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : SingleQuoteStatusProducer { - override val fallback: QuoteStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId) + private val default = QuoteStatus(rawCurrencyId = params.rawCurrencyId) + override val fallback: Option = default.some() override fun produce(): Flow { return quotesStatusesStore.get() - .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } ?: fallback } + .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } ?: default } .distinctUntilChanged() .flowOn(dispatchers.default) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt index 09e9f415de..f66ec71196 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.staking.multi +import arrow.core.Option +import arrow.core.some import com.tangem.data.staking.store.YieldsBalancesStore import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.staking.multi.MultiYieldBalanceProducer @@ -27,8 +29,7 @@ internal class DefaultMultiYieldBalanceProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : MultiYieldBalanceProducer { - override val fallback: Set - get() = setOf() + override val fallback: Option> = emptySet().some() override fun produce(): Flow> { return yieldsBalancesStore.get(userWalletId = params.userWalletId) diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt index 9abbd5db14..3dccb55dfc 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.staking.single +import arrow.core.Option +import arrow.core.some import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.domain.models.staking.YieldBalance @@ -34,9 +36,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : SingleYieldBalanceProducer { - override val fallback: YieldBalance by lazy { - YieldBalance.Error(stakingId = params.stakingId) - } + override val fallback: Option = YieldBalance.Error(stakingId = params.stakingId).some() override fun produce(): Flow { Timber.i("Producing yield balance for params:\n$params") diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt index a959276e85..dc71df52b3 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt @@ -1,5 +1,7 @@ package com.tangem.data.tokens +import arrow.core.Option +import arrow.core.some import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -31,8 +33,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constr private val dispatchers: CoroutineDispatcherProvider, ) : MultiWalletCryptoCurrenciesProducer { - override val fallback: Set - get() = emptySet() + override val fallback: Option> = emptySet().some() override fun produce(): Flow> { val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId) @@ -51,7 +52,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constr userWallet = userWallet, ).toSet() } - .onEmpty { emit(fallback) } + .onEmpty { emit(emptySet()) } .flowOn(dispatchers.default) } diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt index 92e95f552d..8984ab4e36 100644 --- a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt @@ -1,5 +1,6 @@ package com.tangem.domain.core.flow +import arrow.core.Option import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.retryWhen @@ -14,7 +15,7 @@ import kotlinx.coroutines.flow.retryWhen interface FlowProducer { /** Fallback value if [Flow] throws exception */ - val fallback: Data + val fallback: Option /** Produce [Flow] */ fun produce(): Flow @@ -22,7 +23,7 @@ interface FlowProducer { /** Produce [Flow] with retry mechanism */ fun produceWithFallback(): Flow { return produce().retryWhen { _, _ -> - emit(value = fallback) + fallback.onSome { emit(value = it) } delay(timeMillis = 2000) diff --git a/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt b/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt index 42822e7796..674ad5a9d6 100644 --- a/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt +++ b/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt @@ -1,5 +1,7 @@ package com.tangem.domain.core.flow +import arrow.core.Option +import arrow.core.some import com.google.common.truth.Truth import io.mockk.every import io.mockk.mockk @@ -105,8 +107,8 @@ internal class FlowCachingSupplierTest { private class MockFlowProducer(private val params: Int) : FlowProducer { - override val fallback: String - get() = "fallback" + override val fallback: Option + get() = "fallback".some() override fun produce(): Flow = flowOf("test_$params") @@ -125,8 +127,8 @@ internal class FlowCachingSupplierTest { private class MockErrorFlowProducer : FlowProducer { - override val fallback: String - get() = "fallback" + override val fallback: Option + get() = "fallback".some() override fun produce(): Flow = flow { throw IllegalStateException() From 18474637b9e74958c0e65a2cce4d6bec9306bd88 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Sep 2025 15:29:49 +0700 Subject: [PATCH 108/132] Updated on 2026-08-14 --- .../tangem/common/ui/account/AccountIcon.kt | 132 ++++-------------- .../tangem/common/ui/account/AccountRow.kt | 1 + .../core/ui/components/account/AccountIcon.kt | 132 ++++++++++++++++++ .../components/currency/icon/ContentIcon.kt | 15 ++ .../components/currency/icon/CurrencyIcon.kt | 2 + .../currency/icon/CurrencyIconState.kt | 24 ++++ .../core/ui/components/token/TokenItem.kt | 47 ++++++- .../createedit/ui/AccountCreateEditContent.kt | 1 + 8 files changed, 249 insertions(+), 105 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt index 8a441922f5..377c483f00 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt @@ -1,43 +1,31 @@ package com.tangem.common.ui.account -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountIconPreviewData.randomAccountIcon +import com.tangem.core.ui.components.account.AccountCharIcon +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.AccountResIcon import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon.Color -enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall -} - /** - * Displays an account icon that can either show a letter (derived from [name]) - * or a predefined vector resource (from [icon]). + * Displays a portfolio account icon that can either show: + * - a single character (derived from the [name]) using [AccountCharIcon], or + * - a predefined vector resource (from [icon]) using [AccountResIcon]. * - * The background color is determined by the icon's [CryptoPortfolioIconUM.color], - * and the icon size, text style, and box modifier are adapted based on the given [size]. + * The background color is taken from [CryptoPortfolioIconUM.color], + * and the icon size, text style, and container shape are adapted based on the given [size]. * - * @param name The text reference used to resolve and display the first letter - * when [icon] is set to [CryptoPortfolioIcon.Icon.Letter]. + * @param name A [TextReference] used to resolve and display the first letter + * when [icon.value] is set to [CryptoPortfolioIcon.Icon.Letter]. * @param icon The account icon definition, which can be a letter or a drawable resource. - * @param size The size of the icon, defined by [AccountIconSize]. + * @param size The icon size, defined by [AccountIconSize]. + * + * @see AccountCharIcon + * @see AccountResIcon + * @see AccountIconSize */ @Composable fun AccountIcon( @@ -46,82 +34,20 @@ fun AccountIcon( size: AccountIconSize, modifier: Modifier = Modifier, ) { - val boxModifier = modifier.selectBoxModifier(size) - val iconSize = Modifier.selectIconSize(size) - val textStyle = when (size) { - AccountIconSize.Default -> TangemTheme.typography.h3 - AccountIconSize.Large -> TangemTheme.typography.h1 - AccountIconSize.Medium -> TangemTheme.typography.subtitle1 - AccountIconSize.Small -> TangemTheme.typography.subtitle2 - AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 - } - Box( - contentAlignment = Alignment.Center, - modifier = boxModifier.background(icon.color.getUiColor()), - ) { - val icon = icon.value - val letter = name.resolveReference().firstOrNull() - when { - icon == CryptoPortfolioIcon.Icon.Letter -> Text( - text = letter?.uppercase() ?: "", - style = textStyle, - color = TangemTheme.colors.text.constantWhite, - ) - else -> Icon( - modifier = iconSize, - tint = TangemTheme.colors.text.constantWhite, - imageVector = ImageVector.vectorResource(id = icon.getResId()), - contentDescription = null, - ) - } - } -} - -private fun Modifier.selectIconSize(size: AccountIconSize): Modifier = when (size) { - AccountIconSize.Default -> this.size(20.dp) - AccountIconSize.Large -> this.size(40.dp) - AccountIconSize.Medium -> this.size(16.dp) - AccountIconSize.Small -> this.size(12.dp) - AccountIconSize.ExtraSmall -> this.size(8.dp) -} - -private fun Modifier.selectBoxModifier(size: AccountIconSize): Modifier = when (size) { - AccountIconSize.Default -> size(36.dp).clip(RoundedCornerShape(10.dp)) - AccountIconSize.Large -> size(88.dp).clip(RoundedCornerShape(24.dp)) - AccountIconSize.Medium -> size(28.dp).clip(RoundedCornerShape(8.dp)) - AccountIconSize.Small -> size(20.dp).clip(RoundedCornerShape(6.dp)) - AccountIconSize.ExtraSmall -> size(14.dp).clip(RoundedCornerShape(4.dp)) -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_AccountIcon() { - TangemThemePreview { - Sample() - } -} - -@Composable -private fun Sample() { - val name = stringReference("Account Name") - Row( - modifier = Modifier.background(TangemTheme.colors.background.primary), - ) { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Default) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Large) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Medium) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.Small) - AccountIcon(name = name, randomAccountIcon(), size = AccountIconSize.ExtraSmall) - } - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Default) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Large) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Medium) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.Small) - AccountIcon(name = name, randomAccountIcon(letter = true), size = AccountIconSize.ExtraSmall) - } + val letter = name.resolveReference().firstOrNull() + when { + icon.value == CryptoPortfolioIcon.Icon.Letter -> AccountCharIcon( + char = letter ?: 'N', + color = icon.color.getUiColor(), + size = size, + modifier = modifier, + ) + else -> AccountResIcon( + resId = icon.value.getResId(), + color = icon.color.getUiColor(), + size = size, + modifier = modifier, + ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt index d02fe62c15..0b51e38465 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.R +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt new file mode 100644 index 0000000000..ab7844aaa5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -0,0 +1,132 @@ +package com.tangem.core.ui.components.account + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +enum class AccountIconSize { + Default, Large, Medium, Small, ExtraSmall +} + +/** + * Displays a portfolio icon using a predefined vector resource. + * + * The background color is defined by [color], and the icon tint is constant white. + * The icon size and container shape are adapted based on the provided [size]. + * + * @param resId The vector drawable resource ID to display. + * @param color The background color of the icon container. + * @param size The size of the icon, defined by [AccountIconSize]. + */ +@Composable +fun AccountResIcon(@DrawableRes resId: Int, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) { + val boxModifier = modifier.selectBoxModifier(size) + val iconSize = Modifier.selectIconSize(size) + Box( + contentAlignment = Alignment.Center, + modifier = boxModifier.background(color), + ) { + Icon( + modifier = iconSize, + tint = TangemTheme.colors.text.constantWhite, + imageVector = ImageVector.vectorResource(id = resId), + contentDescription = null, + ) + } +} + +/** + * Displays a portfolio icon using a single character. + * + * The background color is defined by [color], the text is uppercased, + * and its style is chosen based on the provided [size]. + * The container shape and size also adapt to [size]. + * + * @param char The character to display inside the icon. + * @param color The background color of the icon container. + * @param size The size of the icon, defined by [AccountIconSize]. + */ +@Composable +fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) { + val boxModifier = modifier.selectBoxModifier(size) + val textStyle = when (size) { + AccountIconSize.Default -> TangemTheme.typography.h3 + AccountIconSize.Large -> TangemTheme.typography.h1 + AccountIconSize.Medium -> TangemTheme.typography.subtitle1 + AccountIconSize.Small -> TangemTheme.typography.subtitle2 + AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 + } + Box( + contentAlignment = Alignment.Center, + modifier = boxModifier.background(color), + ) { + Text( + text = char.uppercase(), + style = textStyle, + color = TangemTheme.colors.text.constantWhite, + ) + } +} + +private fun Modifier.selectIconSize(size: AccountIconSize): Modifier = when (size) { + AccountIconSize.Default -> this.size(20.dp) + AccountIconSize.Large -> this.size(40.dp) + AccountIconSize.Medium -> this.size(16.dp) + AccountIconSize.Small -> this.size(12.dp) + AccountIconSize.ExtraSmall -> this.size(8.dp) +} + +private fun Modifier.selectBoxModifier(size: AccountIconSize): Modifier = when (size) { + AccountIconSize.Default -> size(36.dp).clip(RoundedCornerShape(10.dp)) + AccountIconSize.Large -> size(88.dp).clip(RoundedCornerShape(24.dp)) + AccountIconSize.Medium -> size(28.dp).clip(RoundedCornerShape(8.dp)) + AccountIconSize.Small -> size(20.dp).clip(RoundedCornerShape(6.dp)) + AccountIconSize.ExtraSmall -> size(14.dp).clip(RoundedCornerShape(4.dp)) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + Row( + modifier = Modifier.background(TangemTheme.colors.background.primary), + ) { + Sample() + } + } +} + +@Composable +private fun Sample() { + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { + AccountResIcon(resId = R.drawable.ic_shirt_24, color = Color.Red, size = AccountIconSize.Default) + AccountResIcon(resId = R.drawable.ic_rounded_star_24, color = Color.Blue, size = AccountIconSize.Large) + AccountResIcon(resId = R.drawable.ic_user_24, color = Color.Magenta, size = AccountIconSize.Medium) + AccountResIcon(resId = R.drawable.ic_family_24, color = Color.DarkGray, size = AccountIconSize.Small) + AccountResIcon(resId = R.drawable.ic_wallet_24, color = Color.Green, size = AccountIconSize.ExtraSmall) + } + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { + AccountCharIcon(char = 'D', color = Color.Red, size = AccountIconSize.Default) + AccountCharIcon(char = 'L', color = Color.Blue, size = AccountIconSize.Large) + AccountCharIcon(char = 'M', color = Color.Magenta, size = AccountIconSize.Medium) + AccountCharIcon(char = 'S', color = Color.DarkGray, size = AccountIconSize.Small) + AccountCharIcon(char = 'E', color = Color.Green, size = AccountIconSize.ExtraSmall) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt index 52d7c920a6..4213831216 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt @@ -13,6 +13,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import com.tangem.core.ui.R +import com.tangem.core.ui.components.account.AccountCharIcon +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.AccountResIcon import com.tangem.core.ui.components.currency.DefaultCurrencyIcon import com.tangem.core.ui.res.TangemTheme @@ -58,6 +61,18 @@ internal fun ContentIcon( background = icon.background, alpha = alpha, ) + is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( + modifier = modifier, + resId = icon.resId, + color = icon.color, + size = AccountIconSize.Default, + ) + is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon( + modifier = modifier, + char = icon.char, + color = icon.color, + size = AccountIconSize.Default, + ) CurrencyIconState.Loading, CurrencyIconState.Locked, is CurrencyIconState.Empty, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index ff97530009..04ede1d5ba 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -48,6 +48,8 @@ fun CurrencyIcon( is CurrencyIconState.FiatIcon, is CurrencyIconState.CustomTokenIcon, is CurrencyIconState.TokenIcon, + is CurrencyIconState.CryptoPortfolio.Icon, + is CurrencyIconState.CryptoPortfolio.Letter, -> { ContentIconContainer( icon = state, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 5ebf4ad190..2075631d63 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -86,6 +86,24 @@ sealed class CurrencyIconState { override val topBadgeIconResId: Int? = null } + sealed class CryptoPortfolio : CurrencyIconState() { + override val showCustomBadge: Boolean = false + override val topBadgeIconResId: Int? = null + abstract val color: Color + + data class Icon( + @DrawableRes val resId: Int, + override val color: Color, + override val isGrayscale: Boolean, + ) : CryptoPortfolio() + + data class Letter( + val char: Char, + override val color: Color, + override val isGrayscale: Boolean, + ) : CryptoPortfolio() + } + data object Loading : CurrencyIconState() { override val isGrayscale: Boolean = false override val showCustomBadge: Boolean = false @@ -125,6 +143,12 @@ sealed class CurrencyIconState { showCustomBadge = showCustomBadge, topBadgeIconResId = topBadgeIconResId, ) + is CryptoPortfolio.Icon -> copy( + isGrayscale = isGrayscale, + ) + is CryptoPortfolio.Letter -> copy( + isGrayscale = isGrayscale, + ) is Loading, is Locked, is Empty, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt index 284885331e..3c74bb41fd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.Placeable @@ -309,7 +310,13 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier }, ) - fiatAmount?.placeRelative(x = layoutWidth - fiatAmount.width, y = verticalPadding) + fiatAmount?.placeRelative( + x = layoutWidth - fiatAmount.width, + y = when (state.subtitle2State) { + null -> (layoutHeight - fiatAmount.height).div(other = 2) + else -> verticalPadding + }, + ) priceChange?.placeRelative( x = icon.width, @@ -462,7 +469,7 @@ private fun calculateLayoutHeight( return max(firstColumnHeight, secondColumnHeight).coerceAtLeast(minLayoutHeight) } -@Preview(widthDp = 360) +@Preview(widthDp = 360, showBackground = true) @Composable private fun Preview_TokenItem_InLight(@PreviewParameter(TokenItemStateProvider::class) state: TokenItemState) { TangemThemePreview(isDark = false) { @@ -589,6 +596,8 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider Date: Thu, 11 Sep 2025 13:55:41 +0700 Subject: [PATCH 109/132] Updated on 2026-08-14 --- ...ccountCryptoPortfolioItemStateConverter.kt | 100 ++++++++++++ .../account/AccountIconItemStateConverter.kt | 24 +++ .../modal/TangemModalBottomSheet.kt | 94 +++++++---- .../components/currency/icon/ContentIcon.kt | 3 +- .../currency/icon/CurrencyIconState.kt | 4 +- .../core/ui/components/token/TokenItem.kt | 2 +- features/account/api/build.gradle.kts | 5 + .../account/AccountSelectorComponent.kt | 24 +++ .../account/AccountsBalanceFetcher.kt | 39 +++++ features/account/impl/build.gradle.kts | 9 ++ .../fetcher/DefaultAccountsBalanceFetcher.kt | 120 ++++++++++++++ .../account/selector/AccountSelectorModel.kt | 87 +++++++++++ .../DefaultAccountSelectorComponent.kt | 69 +++++++++ .../DefaultAccountSelectorController.kt | 18 +++ .../selector/entity/AccountSelectorUM.kt | 28 ++++ .../account/selector/ui/AccountSelectorBS.kt | 61 ++++++++ .../selector/ui/AccountSelectorContent.kt | 146 ++++++++++++++++++ 17 files changed, 796 insertions(+), 37 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt create mode 100644 features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt create mode 100644 features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt new file mode 100644 index 0000000000..e2b2b60cd9 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -0,0 +1,100 @@ +package com.tangem.common.ui.account + +import com.tangem.common.ui.R +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.utils.converter.Converter + +class AccountCryptoPortfolioItemStateConverter( + private val appCurrency: AppCurrency, + private val account: Account.CryptoPortfolio, + private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null, + private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null, +) : Converter { + + override fun convert(value: TotalFiatBalance): TokenItemState { + return when (value) { + is TotalFiatBalance.Loaded -> account.mapToContentState(value) + TotalFiatBalance.Failed -> account.mapToUnreachableState() + TotalFiatBalance.Loading -> account.mapToLoadingState() + } + } + + private fun Account.CryptoPortfolio.mapToContentState( + fiatBalance: TotalFiatBalance.Loaded, + ): TokenItemState.Content { + return TokenItemState.Content( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(this), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + fiatAmountState = FiatAmountState.Content( + text = fiatBalance.amount + .format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, + isFlickering = fiatBalance.source == StatusSource.CACHE, + ), + subtitle2State = null, + onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, + onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } }, + ) + } + + private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Loading { + return TokenItemState.Loading( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(account), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + ) + } + + private fun Account.CryptoPortfolio.mapToUnreachableState(): TokenItemState.Unreachable { + return TokenItemState.Unreachable( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(account), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = onItemClick?.let { onItemClick -> + { onItemClick(account) } + }, + onItemLongClick = onItemLongClick?.let { onItemLongClick -> + { onItemLongClick(account) } + }, + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt new file mode 100644 index 0000000000..f89a37e3db --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt @@ -0,0 +1,24 @@ +package com.tangem.common.ui.account + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.utils.converter.Converter + +object AccountIconItemStateConverter : Converter { + + override fun convert(value: Account): CurrencyIconState.CryptoPortfolio = when (value) { + is Account.CryptoPortfolio -> when { + value.icon.value == CryptoPortfolioIcon.Icon.Letter -> CurrencyIconState.CryptoPortfolio.Letter( + char = value.accountName.toUM().value, + color = value.icon.color.getUiColor(), + isGrayscale = false, + ) + else -> CurrencyIconState.CryptoPortfolio.Icon( + resId = value.icon.value.getResId(), + color = value.icon.color.getUiColor(), + isGrayscale = false, + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index 0c76ab802c..196a5f2012 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -50,6 +50,7 @@ inline fun TangemModalBottomSheet( containerColor: Color = TangemTheme.colors.background.primary, skipPartiallyExpanded: Boolean = true, dismissOnClickOutside: Boolean = true, + scrollableContent: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable ColumnScope.(T) -> Unit, @@ -62,6 +63,7 @@ inline fun TangemModalBottomSheet( containerColor = containerColor, title = title, content = content, + scrollableContent = scrollableContent, skipPartiallyExpanded = skipPartiallyExpanded, ) } else { @@ -71,6 +73,7 @@ inline fun TangemModalBottomSheet( title = title, content = content, dismissOnClickOutside = dismissOnClickOutside, + scrollableContent = scrollableContent, onBack = onBack, skipPartiallyExpanded = skipPartiallyExpanded, ) @@ -84,6 +87,7 @@ inline fun DefaultModalBottomSheet( containerColor: Color, skipPartiallyExpanded: Boolean = true, dismissOnClickOutside: Boolean = true, + scrollableContent: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), @@ -104,10 +108,16 @@ inline fun DefaultModalBottomSheet( BasicModalBottomSheet( config = config, sheetState = sheetState, - containerColor = containerColor, - title = title, onBack = onBack, - content = content, + bsContent = { + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) + }, ) } @@ -126,6 +136,7 @@ inline fun PreviewModalBottomSheet( config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, + scrollableContent: Boolean = true, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { @@ -137,49 +148,64 @@ inline fun PreviewModalBottomSheet( density = LocalDensity.current, ), onBack = null, - containerColor = containerColor, - title = title, - content = content, + bsContent = { + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) + }, ) } +@Composable +inline fun BsContent( + config: TangemBottomSheetConfig, + containerColor: Color, + scrollableContent: Boolean = true, + crossinline title: @Composable (BoxScope.(T) -> Unit), + crossinline content: @Composable (ColumnScope.(T) -> Unit), +) { + val model = config.content as? T ?: return + + val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT + + Column( + modifier = Modifier + .systemBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp) + .clip(TangemTheme.shapes.roundedCornersLarge) + .background(containerColor) + .heightIn(max = maxHeight.dp) + .fillMaxWidth(), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + title(model) + } + if (scrollableContent) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + content(model) + } + } else { + content(model) + } + } +} + @Suppress("LongParameterList") @OptIn(ExperimentalMaterial3Api::class) @Composable inline fun BasicModalBottomSheet( config: TangemBottomSheetConfig, sheetState: SheetState, - containerColor: Color, noinline onBack: (() -> Unit)? = null, - crossinline title: @Composable (BoxScope.(T) -> Unit), - crossinline content: @Composable (ColumnScope.(T) -> Unit), + noinline bsContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, ) { - val model = config.content as? T ?: return - - val bsContent: @Composable ColumnScope.() -> Unit = { - val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT - - Column( - modifier = Modifier - .systemBarsPadding() - .padding(horizontal = 8.dp, vertical = 8.dp) - .clip(TangemTheme.shapes.roundedCornersLarge) - .background(containerColor) - .heightIn(max = maxHeight.dp) - .fillMaxWidth(), - ) { - Box(modifier = Modifier.fillMaxWidth()) { - title(model) - } - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - content(model) - } - } - } - if (onBack != null) { ModalBottomSheetWithBackHandling( modifier = modifier, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt index 4213831216..02e42068d6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.account.AccountResIcon import com.tangem.core.ui.components.currency.DefaultCurrencyIcon +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @Composable @@ -69,7 +70,7 @@ internal fun ContentIcon( ) is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon( modifier = modifier, - char = icon.char, + char = icon.char.resolveReference().first(), color = icon.color, size = AccountIconSize.Default, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 2075631d63..65107776ea 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference /** * Represents the various states an icon can be in. @@ -86,6 +87,7 @@ sealed class CurrencyIconState { override val topBadgeIconResId: Int? = null } + @Immutable sealed class CryptoPortfolio : CurrencyIconState() { override val showCustomBadge: Boolean = false override val topBadgeIconResId: Int? = null @@ -98,7 +100,7 @@ sealed class CurrencyIconState { ) : CryptoPortfolio() data class Letter( - val char: Char, + val char: TextReference, override val color: Color, override val isGrayscale: Boolean, ) : CryptoPortfolio() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt index 3c74bb41fd..9a4585eb59 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt @@ -661,7 +661,7 @@ object AccountItemPreviewData { ) val accountLetterIcon: CurrencyIconState.CryptoPortfolio.Letter get() = CurrencyIconState.CryptoPortfolio.Letter( - char = 'A', + char = stringReference("A"), color = Color.Blue, isGrayscale = false, ) diff --git a/features/account/api/build.gradle.kts b/features/account/api/build.gradle.kts index e3e35ac4cd..5e9cc443b6 100644 --- a/features/account/api/build.gradle.kts +++ b/features/account/api/build.gradle.kts @@ -13,7 +13,12 @@ dependencies { /* Project - Core */ implementation(projects.core.decompose) implementation(projects.core.ui) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) } \ No newline at end of file diff --git a/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt b/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt new file mode 100644 index 0000000000..4e751499ed --- /dev/null +++ b/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.account + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.account.Account +import kotlinx.coroutines.flow.StateFlow + +interface AccountSelectorComponent : ComposableBottomSheetComponent { + + data class Params( + val onDismiss: () -> Unit, + val accountsBalanceFetcher: AccountsBalanceFetcher, + val controller: AccountSelectorController, + ) + + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): AccountSelectorComponent + } +} + +interface AccountSelectorController { + val selectedAccount: StateFlow + fun selectAccount(account: Account?) +} \ No newline at end of file diff --git a/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt b/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt new file mode 100644 index 0000000000..77283e9678 --- /dev/null +++ b/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt @@ -0,0 +1,39 @@ +package com.tangem.features.account + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListError +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface AccountsBalanceFetcher { + + val data: Flow + + val mode: StateFlow + fun updateMode(mode: Mode) + + data class Data( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val balances: Map>, + ) + + data class AccountBalance( + val balance: Lce, + ) + + sealed interface Mode { + data class All(val onlyMultiCurrency: Boolean) : Mode + data class Wallet(val walletId: UserWalletId) : Mode + } + + interface Factory { + fun create(mode: Mode, scope: CoroutineScope): AccountsBalanceFetcher + } +} \ No newline at end of file diff --git a/features/account/impl/build.gradle.kts b/features/account/impl/build.gradle.kts index dc77a38d28..924e0c0cdc 100644 --- a/features/account/impl/build.gradle.kts +++ b/features/account/impl/build.gradle.kts @@ -29,6 +29,15 @@ dependencies { /** Domain */ implementation(projects.domain.models) implementation(projects.domain.account) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) /** Common */ implementation(projects.common.ui) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt b/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt new file mode 100644 index 0000000000..2d9db0f8a7 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt @@ -0,0 +1,120 @@ +package com.tangem.features.account.fetcher + +import arrow.core.getOrElse +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.account.AccountsBalanceFetcher +import com.tangem.features.account.AccountsBalanceFetcher.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class DefaultAccountsBalanceFetcher @AssistedInject constructor( + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getWallets: GetWalletsUseCase, + private val dispatchers: CoroutineDispatcherProvider, + @Assisted mode: Mode, + @Assisted private val scope: CoroutineScope, +) : AccountsBalanceFetcher { + + private val _mode = MutableStateFlow(mode) + private val _data = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val data: Flow + get() = _data.distinctUntilChanged() + override val mode: StateFlow + get() = _mode + + init { + _mode.flatMapLatest(::combineUseCases) + .flowOn(dispatchers.default) + .onEach { _data.emit(it) } + .launchIn(scope) + } + + override fun updateMode(mode: Mode) { + _mode.value = mode + } + + private fun combineUseCases(mode: Mode) = combine( + flow = getWallets() + .map { it.filterWallets(mode) } + .distinctUntilChanged() + .flatMapLatest { wallets -> balancesForWallets(wallets) }, + flow2 = appCurrencyFlow(), + flow3 = balanceHidingFlow(), + ) { balances, appCurrency, isBalanceHiding -> + Data( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHiding, + balances = balances, + ) + } + + private fun List.filterWallets(mode: Mode): List = this.filter { wallet -> + when (mode) { + is Mode.All -> if (mode.onlyMultiCurrency) wallet.isMultiCurrency else true + is Mode.Wallet -> wallet.walletId == mode.walletId + } + } + + private fun balancesForWallets(wallets: List): Flow>> = + wallets.asFlow() + .map { walletAccountsBalancesFlow(it) } + .mapLatest { accountsBalances -> combine(accountsBalances) { pairs -> pairs.toMap() } } + .flattenConcat() + + private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow>> = + walletAccounts(wallet) + .distinctUntilChanged() + .map { list -> list.map(::accountBalanceFlow) } + .mapLatest { balanceFlows -> combine(balanceFlows) { balances -> balances.toMap() } } + .flattenConcat() + .map { accountBalance -> wallet to accountBalance } + + private fun walletAccounts(wallet: UserWallet): Flow> = flow { + // todo account load accounts + val accounts: List = Account.CryptoPortfolio + .createMainAccount(wallet.walletId) + .let(::listOf) + emit(accounts) + } + + private fun accountBalanceFlow(account: Account): Flow> = flow { + // todo account load balance + val balance = AccountBalance(balance = Lce.Content(TotalFiatBalance.Loading)) + emit(account to balance) + } + + private fun appCurrencyFlow(): Flow { + return getSelectedAppCurrencyUseCase() + .map { it.getOrElse { AppCurrency.Default } } + .distinctUntilChanged() + } + + private fun balanceHidingFlow(): Flow { + return getBalanceHidingSettingsUseCase() + .map { it.isBalanceHidden } + .distinctUntilChanged() + } + + @AssistedFactory + interface Factory : AccountsBalanceFetcher.Factory { + override fun create(mode: Mode, scope: CoroutineScope): DefaultAccountsBalanceFetcher + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt new file mode 100644 index 0000000000..5012e93d14 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt @@ -0,0 +1,87 @@ +package com.tangem.features.account.selector + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +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.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.utils.getOrElse +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.features.account.AccountSelectorComponent +import com.tangem.features.account.AccountsBalanceFetcher +import com.tangem.features.account.selector.entity.AccountSelectorItemUM +import com.tangem.features.account.selector.entity.AccountSelectorUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@ModelScoped +internal class AccountSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + private val balanceFetcher get() = params.accountsBalanceFetcher + private val selectorController get() = params.controller + + internal val state: StateFlow + field = MutableStateFlow(emptyState()) + + init { + balanceFetcher.data + .map { data -> + AccountSelectorUM( + isSingleWallet = balanceFetcher.mode.value is AccountsBalanceFetcher.Mode.Wallet, + items = buildUiList(data).toImmutableList(), + ) + } + .launchIn(modelScope) + } + + private fun buildUiList(data: AccountsBalanceFetcher.Data) = buildList { + fun Account.accountItemState(balance: Lce): TokenItemState { + val totalFiatBalance = balance.getOrElse( + ifError = { TotalFiatBalance.Failed }, + ifLoading = { TotalFiatBalance.Loading }, + ) + return when (this) { + is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter( + appCurrency = data.appCurrency, + account = this, + onItemClick = { selectorController.selectAccount(it) }, + ).convert(totalFiatBalance) + } + } + + data.balances.forEach { wallet, accounts -> + if (wallet.isLocked) return@forEach + AccountSelectorItemUM.Wallet( + id = wallet.walletId.stringValue, + name = stringReference(wallet.name), + ).let(::add) + + accounts.forEach { account, balance -> + AccountSelectorItemUM.Account( + account = account.accountItemState(balance.balance), + isBalanceHidden = data.isBalanceHidden, + ).let(::add) + } + } + } + + private fun emptyState() = AccountSelectorUM( + items = persistentListOf(), + balanceFetcher.mode.value is AccountsBalanceFetcher.Mode.Wallet, + ) +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt new file mode 100644 index 0000000000..bc53b7bfb3 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt @@ -0,0 +1,69 @@ +package com.tangem.features.account.selector + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.account.AccountSelectorComponent +import com.tangem.features.account.impl.R +import com.tangem.features.account.selector.ui.AccountSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAccountSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: AccountSelectorComponent.Params, +) : AppComponentContext by appComponentContext, AccountSelectorComponent { + + private val model: AccountSelectorModel = getOrCreateModel(params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.state.collectAsStateWithLifecycle() + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = ::dismiss, + containerColor = TangemTheme.colors.background.primary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.common_choose_wallet), + startIconRes = R.drawable.ic_back_24, + onStartClick = ::dismiss, + ) + }, + content = { + AccountSelectorContent( + state = state, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + }, + ) + } + + @AssistedFactory + interface Factory : AccountSelectorComponent.Factory { + override fun create( + appComponentContext: AppComponentContext, + params: AccountSelectorComponent.Params, + ): DefaultAccountSelectorComponent + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt new file mode 100644 index 0000000000..9be5ae3197 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt @@ -0,0 +1,18 @@ +package com.tangem.features.account.selector + +import com.tangem.domain.models.account.Account +import com.tangem.features.account.AccountSelectorController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +internal class DefaultAccountSelectorController @Inject constructor() : AccountSelectorController { + + private val _selectedAccount: MutableStateFlow = MutableStateFlow(null) + override val selectedAccount: StateFlow get() = _selectedAccount + + override fun selectAccount(account: Account?) { + _selectedAccount.update { account } + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt new file mode 100644 index 0000000000..d8d05f0fea --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt @@ -0,0 +1,28 @@ +package com.tangem.features.account.selector.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +data class AccountSelectorUM( + val items: ImmutableList, + val isSingleWallet: Boolean, +) + +@Immutable +sealed interface AccountSelectorItemUM { + val id: String + + data class Wallet( + override val id: String, + val name: TextReference, + ) : AccountSelectorItemUM + + data class Account( + val account: TokenItemState, + val isBalanceHidden: Boolean, + ) : AccountSelectorItemUM { + override val id: String = account.id + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt new file mode 100644 index 0000000000..ec1cad3c9e --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt @@ -0,0 +1,61 @@ +package com.tangem.features.account.selector.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.account.impl.R +import com.tangem.features.account.selector.entity.AccountSelectorUM + +@Composable +internal fun AccountSelectorBS(state: AccountSelectorUM, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onDismiss, + scrollableContent = false, + containerColor = TangemTheme.colors.background.secondary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.common_choose_account), + startIconRes = R.drawable.ic_back_24, + onStartClick = onDismiss, + ) + }, + content = { + AccountSelectorContent( + state = state, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = modifier.padding(horizontal = 16.dp), + ) + }, + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(AccountSelectorPreviewStateProvider::class) params: AccountSelectorUM) { + TangemThemePreview { + AccountSelectorBS( + state = params, + onDismiss = {}, + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt new file mode 100644 index 0000000000..a8f54b000d --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt @@ -0,0 +1,146 @@ +package com.tangem.features.account.selector.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +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.token.AccountItemPreviewData +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.account.selector.entity.AccountSelectorItemUM +import com.tangem.features.account.selector.entity.AccountSelectorUM +import com.tangem.features.account.selector.ui.AccountSelectorPreviewData.firstList +import kotlinx.collections.immutable.toImmutableList +import java.util.UUID + +@Composable +internal fun AccountSelectorContent( + state: AccountSelectorUM, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + ) { + val items = state.items + itemsIndexed( + items = items, + key = { _, item -> item.id }, + ) { index, item -> + val previewItem = items.getOrNull(index.dec()) + val offsetModifier = when { + previewItem == null -> Modifier + state.isSingleWallet -> Modifier.padding( + top = TangemTheme.dimens.spacing16, + ) + item is AccountSelectorItemUM.Wallet -> Modifier.padding( + top = TangemTheme.dimens.spacing16, + ) + else -> Modifier.padding( + top = TangemTheme.dimens.spacing8, + ) + } + + when (item) { + is AccountSelectorItemUM.Account -> TokenItem( + state = item.account, + isBalanceHidden = item.isBalanceHidden, + modifier = offsetModifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.primary), + ) + is AccountSelectorItemUM.Wallet -> WalletNameRow( + model = item, + modifier = offsetModifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + } + } +} + +@Composable +private fun WalletNameRow(model: AccountSelectorItemUM.Wallet, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = model.name.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AccountSelectorContentPreview( + @PreviewParameter(AccountSelectorPreviewStateProvider::class) params: AccountSelectorUM, +) { + TangemThemePreview { + AccountSelectorContent( + state = params, + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) + } +} + +internal object AccountSelectorPreviewData { + val firstList + get() = buildList { + AccountSelectorItemUM.Wallet( + id = UUID.randomUUID().toString(), + name = stringReference("Tangem 2.0"), + ).let(::add) + AccountItemPreviewData.accountItem + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon) + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + AccountSelectorItemUM.Wallet( + id = UUID.randomUUID().toString(), + name = stringReference("Tangem White"), + ).let(::add) + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon) + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + } +} + +internal class AccountSelectorPreviewStateProvider : CollectionPreviewParameterProvider( + buildList { + val secondList = listOf( + AccountItemPreviewData.accountItem, + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon), + ).map { AccountSelectorItemUM.Account(it, false) } + + val first = AccountSelectorUM( + items = firstList.toImmutableList(), + isSingleWallet = false, + ) + val second = AccountSelectorUM( + items = secondList.toImmutableList(), + isSingleWallet = true, + ) + add(first) + add(second) + }, +) \ No newline at end of file From a5a8cee12c061728506e049692046d8b212ebc3f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Sep 2025 12:28:45 +0300 Subject: [PATCH 110/132] Updated on 2026-08-14 --- .../com/tangem/common/extensions/KViewExt.kt | 7 + .../com/tangem/scenarios/BaseScenarios.kt | 2 + .../com/tangem/scenarios/DialogScenarios.kt | 10 +- .../AlreadyUsedWalletDialogPageObject.kt | 19 +- .../kotlin/com/tangem/screens/BaseDialog.kt | 14 + .../tangem/screens/MainScreenPageObject.kt | 33 +- .../kotlin/com/tangem/tests/FeedbackTest.kt | 13 +- .../kotlin/com/tangem/tests/ResetCardTest.kt | 2 - .../com/tangem/tests/WalletConnectTest.kt | 6 +- .../kotlin/com/tangem/tests/WarningTest.kt | 43 +++ .../tap/domain/sdk/mocks/MockProvider.kt | 4 +- .../Wallet2WithSeedPhraseMockContent.kt | 307 ++++++++++++++++++ 12 files changed, 436 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/common/extensions/KViewExt.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/BaseDialog.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/KViewExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KViewExt.kt new file mode 100644 index 0000000000..c0b6c55687 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KViewExt.kt @@ -0,0 +1,7 @@ +package com.tangem.common.extensions + +import io.github.kakaocup.kakao.common.views.KBaseView + +fun > T.withDialogRoot(): T { + return this.also { inRoot { isDialog() } } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 69e061f616..ad6edeb6d9 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -4,6 +4,7 @@ import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType import com.tangem.screens.* +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockProvider import io.qameta.allure.kotlin.Allure.step @@ -27,6 +28,7 @@ fun BaseTestCase.openMainScreen( } if (alreadyActivatedDialogIsShown) { step("Click on 'This is my wallet' button") { + composeTestRule.waitForIdle() AlreadyUsedWalletDialogPageObject { thisIsMyWalletButton.click() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt index 0b7990da23..c7f2d7f954 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt @@ -2,11 +2,19 @@ package com.tangem.scenarios import com.tangem.common.BaseTestCase import com.tangem.screens.AlreadyUsedWalletDialogPageObject +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.cancelButton +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.message +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.requestSupportButton +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.title import com.tangem.screens.ScanWarningDialogPageObject import com.tangem.screens.onFailedTransactionDialog import io.qameta.allure.kotlin.Allure.step fun BaseTestCase.checkFailedTransactionDialog() { + step("Assert failed transaction dialog is displayed") { + onFailedTransactionDialog { dialogContainer.assertIsDisplayed() } + } step("Assert failed transaction dialog title is displayed") { onFailedTransactionDialog { title.assertIsDisplayed() } } @@ -47,7 +55,7 @@ fun checkAlreadyUsedWalletDialog() { AlreadyUsedWalletDialogPageObject { message.isDisplayed() } } step("Assert 'This is my wallet' button is displayed") { - AlreadyUsedWalletDialogPageObject { message.isDisplayed() } + AlreadyUsedWalletDialogPageObject { thisIsMyWalletButton.isDisplayed() } } step("Assert 'Cancel' button is displayed") { AlreadyUsedWalletDialogPageObject { cancelButton.isDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt index 7f5e42cc48..e7e11531ba 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/AlreadyUsedWalletDialogPageObject.kt @@ -1,32 +1,29 @@ package com.tangem.screens -import com.kaspersky.kaspresso.screens.KScreen +import com.tangem.common.extensions.withDialogRoot import com.tangem.wallet.R -import io.github.kakaocup.kakao.text.KTextView import io.github.kakaocup.kakao.text.KButton +import io.github.kakaocup.kakao.text.KTextView -object AlreadyUsedWalletDialogPageObject : KScreen() { - - override val layoutId: Int? = null - override val viewClass: Class<*>? = null +object AlreadyUsedWalletDialogPageObject : BaseDialog() { val title = KTextView { withText(R.string.security_alert_title) - } + }.withDialogRoot() val message = KTextView { withText(R.string.wallet_been_activated_message) - } + }.withDialogRoot() val cancelButton = KButton { withText(R.string.common_cancel) - } + }.withDialogRoot() val requestSupportButton = KButton { withText(R.string.alert_button_request_support) - } + }.withDialogRoot() val thisIsMyWalletButton = KButton { withText(R.string.this_is_my_wallet_title) - } + }.withDialogRoot() } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BaseDialog.kt b/app/src/androidTest/kotlin/com/tangem/screens/BaseDialog.kt new file mode 100644 index 0000000000..383331368a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/BaseDialog.kt @@ -0,0 +1,14 @@ +package com.tangem.screens + +import io.github.kakaocup.kakao.common.views.KBaseView + +abstract class BaseDialog : KBaseView({ isRoot() }) { + + init { + setDialogRoot() + } + + private fun setDialogRoot() { + inRoot { isDialog() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 7c145b6ff8..4f14e268c2 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -7,9 +7,10 @@ import androidx.compose.ui.test.hasAnyAncestor import com.tangem.common.BaseTestCase import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.MainScreenTestTags -import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.NotificationTestTags +import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.feature.wallet.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen @@ -73,6 +74,24 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + val seedPhraseNotificationIcon: KNode = child { + hasAnySibling(withText(getResourceString(R.string.warning_seedphrase_issue_title))) + hasTestTag(NotificationTestTags.ICON) + useUnmergedTree = true + } + + val seedPhraseNotificationTitle: KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_seedphrase_issue_title)) + useUnmergedTree = true + } + + val seedPhraseNotificationMessage: KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(getResourceString(R.string.warning_seedphrase_issue_message)) + useUnmergedTree = true + } + val totalBalanceContainer: KNode = child { hasTestTag(MainScreenTestTags.WALLET_LIST_ITEM) } @@ -91,6 +110,18 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasParent(withTestTag(MainScreenTestTags.WALLET_BALANCE)) } + val notificationYesButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_yes)) + useUnmergedTree = true + } + + val notificationNoButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_no)) + useUnmergedTree = true + } + /** * Find token list item with title and address */ diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index f2426d8093..9742ee2362 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -4,12 +4,14 @@ import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiT import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType import com.tangem.domain.redux.StateDialog import com.tangem.scenarios.* import com.tangem.screens.* +import com.tangem.screens.AlreadyUsedWalletDialogPageObject.requestSupportButton import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.sdk.mocks.MockProvider import com.tangem.tap.store @@ -17,7 +19,6 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.Allure import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -92,10 +93,15 @@ class FeedbackTest : BaseTestCase() { onSendAddressScreen { nextButton.clickWithAssertion() } } step("Click 'Send' button") { - onSendConfirmScreen { sendButton.clickWithAssertion() } + onSendConfirmScreen { + sendButton.assertIsEnabled() + sendButton.performClick() + } } step("Check 'Failed transaction' dialog") { - checkFailedTransactionDialog() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkFailedTransactionDialog() + } } step("Click on 'Support' button") { onFailedTransactionDialog { supportButton.performClick() } @@ -145,7 +151,6 @@ class FeedbackTest : BaseTestCase() { @AllureId("3986") @DisplayName("Send feedback: from scan already used wallet alert dialog") - @Ignore("TODO On CI Already used wallet doesn't displayed") @Test fun sendFeedbackAfterScanAlreadyUsedWalletTest() { setupHooks( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt index d159dc0eda..50a5f44b6d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ResetCardTest.kt @@ -7,7 +7,6 @@ import com.tangem.tap.domain.sdk.mocks.content.BackupWalletMockContent import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -61,7 +60,6 @@ class ResetCardTest : BaseTestCase() { @AllureId("3974") @DisplayName("Reset card: reset Wallet 2.0 card with backup") - @Ignore("toDo [REDACTED_TASK_KEY]: On CI Already used wallet dialog doesn't displayed") @Test fun resetWallet2CardWithBackupTest() { setupHooks().run { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt index 670444ff59..b0f6b79e78 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -16,7 +16,7 @@ import org.junit.Test @HiltAndroidTest class WalletConnectTest : BaseTestCase() { - @AllureId("3833") + @AllureId("3958") @DisplayName("WC (React App): open session from deeplink on main screen") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test @@ -65,7 +65,7 @@ class WalletConnectTest : BaseTestCase() { } } - @AllureId("3834") + @AllureId("3959") @DisplayName("WC (React App): open session from deeplink not on main screen") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test @@ -120,7 +120,7 @@ class WalletConnectTest : BaseTestCase() { } } - @AllureId("886") + @AllureId("3957") @DisplayName("WC (React App): open session from deeplink ") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt index e3c3937047..cf0024f8bb 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt @@ -1,10 +1,14 @@ package com.tangem.tests import com.tangem.common.BaseTestCase +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen import com.tangem.screens.onMainScreen import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test @@ -51,4 +55,43 @@ class WarningTest : BaseTestCase() { } } } + + @AllureId("227") + @DisplayName("Seed notify: check warning for wallet with seed phrase") + @Test + fun checkWarningForWalletWithSeedPhraseTest() { + val scenarioName = "seedphrase_notification" + val scenarioState = "Notified" + setupHooks( + additionalBeforeSection = { + step("Setup WireMock scenario '$scenarioName' for '$scenarioState' state") { + setWireMockScenarioState(scenarioName, scenarioState) + } + }, + additionalAfterSection = { + step("Reset WireMock scenario '$scenarioName' state") { + resetWireMockScenarioState(scenarioName) + } + } + ).run { + step("Open 'Main' screen") { + openMainScreen(mockContent = Wallet2WithSeedPhraseMockContent, alreadyActivatedDialogIsShown = true) + } + step("Assert 'Seed phrase' notification icon is displayed") { + onMainScreen { seedPhraseNotificationIcon.assertIsDisplayed() } + } + step("Assert 'Seed phrase' notification title is displayed") { + onMainScreen { seedPhraseNotificationTitle.assertIsDisplayed() } + } + step("Assert 'Seed phrase' notification message is displayed") { + onMainScreen { seedPhraseNotificationMessage.assertIsDisplayed() } + } + step("Assert notification 'Yes' button is displayed") { + onMainScreen { notificationYesButton.assertIsDisplayed() } + } + step("Assert notification 'No' button is displayed") { + onMainScreen { notificationNoButton.assertIsDisplayed() } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index 83df27ebb4..3019dbeba0 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -5,9 +5,9 @@ import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.domain.models.scan.ProductType import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent import com.tangem.tap.domain.sdk.mocks.content.NoteMockContent import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent -import com.tangem.tap.domain.sdk.mocks.content.Wallet2MockContent object MockProvider { @@ -67,7 +67,7 @@ object MockProvider { private fun getMockContent(productType: ProductType): MockContent { return when (productType) { ProductType.Wallet -> WalletMockContent - ProductType.Wallet2 -> Wallet2MockContent + ProductType.Wallet2 -> Wallet2WithSeedPhraseMockContent ProductType.Note -> NoteMockContent else -> TODO() } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt new file mode 100644 index 0000000000..06d7e7db26 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object Wallet2WithSeedPhraseMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF05888888880018", + batchId = "AF05", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF05888888880018", + batchId = "AF05", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = true, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = true, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF05888888880018") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file From 9b1bf5813f253cafbf7acd862fd8b47b1f5b1b14 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Sep 2025 13:32:13 +0400 Subject: [PATCH 111/132] Updated on 2026-08-14 --- .../domain/wallet/MockUserWalletFactory.kt | 28 +++ data/account/build.gradle.kts | 1 + .../di/AccountListProducerFactoryModule.kt | 28 +++ .../account/di/AccountListSupplierModule.kt | 34 +++ .../DefaultMultiAccountListProducer.kt | 53 +++++ .../DefaultSingleAccountListProducer.kt | 52 ++++ .../producer/WalletAccountListFlowFactory.kt | 58 +++++ .../DefaultMultiAccountListProducerTest.kt | 222 ++++++++++++++++++ .../DefaultSingleAccountListProducerTest.kt | 198 ++++++++++++++++ .../WalletAccountListFlowFactoryTest.kt | 148 ++++++++++++ .../DefaultCardCryptoCurrencyFactoryTest.kt | 36 +-- .../producer/MultiAccountListProducer.kt | 14 ++ .../producer/SingleAccountListProducer.kt | 17 ++ .../supplier/MultiAccountListSupplier.kt | 21 ++ .../supplier/SingleAccountListSupplier.kt | 15 ++ 15 files changed, 895 insertions(+), 30 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt diff --git a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt index 5f86c429c3..a216096699 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt @@ -1,10 +1,13 @@ package com.tangem.common.test.domain.wallet +import com.tangem.common.card.WalletData import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.models.scan.ProductType 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.wallets.builder.UserWalletIdBuilder /** @@ -29,4 +32,29 @@ object MockUserWalletFactory { hasBackupError = false, ) } + + fun createSingleWalletWithToken(): UserWallet.Cold { + return UserWallet.Cold( + name = "NODL", + walletId = UserWalletId("011"), + cardsInWallet = setOf(), + isMultiCurrency = false, + scanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(maxWalletCount = 2), + derivedKeys = emptyMap(), + ).copy( + productType = ProductType.Note, + walletData = WalletData( + blockchain = "ETH", + token = WalletData.Token( + name = "Ethereum", + symbol = "ETH", + contractAddress = "0x", + decimals = 8, + ), + ), + ), + hasBackupError = false, + ) + } } \ No newline at end of file diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index ace90489e6..d5c297866a 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // region Project - Domain api(projects.domain.account) + api(projects.domain.card) api(projects.domain.models) // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt new file mode 100644 index 0000000000..cfa81cfba4 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.account.di + +import com.tangem.data.account.producer.DefaultMultiAccountListProducer +import com.tangem.data.account.producer.DefaultSingleAccountListProducer +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.account.producer.SingleAccountListProducer +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 AccountListProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleAccountListProducerFactory( + impl: DefaultSingleAccountListProducer.Factory, + ): SingleAccountListProducer.Factory + + @Binds + @Singleton + fun bindMultiAccountListProducerFactory( + impl: DefaultMultiAccountListProducer.Factory, + ): MultiAccountListProducer.Factory +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt new file mode 100644 index 0000000000..c60b4bd0bb --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt @@ -0,0 +1,34 @@ +package com.tangem.data.account.di + +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AccountListSupplierModule { + + @Provides + @Singleton + fun provideSingleAccountListSupplier(factory: SingleAccountListProducer.Factory): SingleAccountListSupplier { + return object : SingleAccountListSupplier( + factory = factory, + keyCreator = { "single_account_list_${it.userWalletId.stringValue}" }, + ) {} + } + + @Provides + @Singleton + fun provideMultiNetworkStatusSupplier(factory: MultiAccountListProducer.Factory): MultiAccountListSupplier { + return object : MultiAccountListSupplier( + factory = factory, + keyCreator = { "multi_networks_statuses" }, + ) {} + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt new file mode 100644 index 0000000000..da4e0e7853 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt @@ -0,0 +1,53 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.some +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +/** + * Default implementation of [MultiAccountListProducer]. + * Produces a list of [AccountList]s for all user wallets. + * + * @property params params + * @property userWalletsStore store that provides user wallets + * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiAccountListProducer @AssistedInject constructor( + @Assisted val params: Unit, + private val userWalletsStore: UserWalletsStore, + private val walletAccountListFlowFactory: WalletAccountListFlowFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiAccountListProducer { + + override val fallback: Option> = emptyList().some() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun produce(): Flow> { + return userWalletsStore.userWallets + .distinctUntilChanged() + .flatMapLatest { userWallets -> + combine( + flows = userWallets.map(walletAccountListFlowFactory::create), + transform = ::listOf, + ) + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiAccountListProducer.Factory { + override fun create(params: Unit): DefaultMultiAccountListProducer + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt new file mode 100644 index 0000000000..43d0745ea2 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -0,0 +1,52 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.none +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleAccountListProducer]. + * Produces a list of [AccountList] for a specific user wallet. + * + * @property params params containing the user wallet ID + * @property userWalletsStore store that provides user wallets + * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleAccountListProducer @AssistedInject constructor( + @Assisted val params: SingleAccountListProducer.Params, + private val userWalletsStore: UserWalletsStore, + private val walletAccountListFlowFactory: WalletAccountListFlowFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleAccountListProducer { + + override val fallback: Option = none() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun produce(): Flow { + return userWalletsStore.userWallets + .mapNotNull { userWallets -> + userWallets.firstOrNull { it.walletId == params.userWalletId } + } + .flatMapLatest(walletAccountListFlowFactory::create) + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleAccountListProducer.Factory { + override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt new file mode 100644 index 0000000000..1f8f47d3c9 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -0,0 +1,58 @@ +package com.tangem.data.account.producer + +import com.tangem.data.account.converter.AccountListConverter +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.requireColdWallet +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +/** + * Factory that creates a flow of [AccountList] for a specific [UserWallet] + * + * @property accountsResponseStoreFactory factory to create [AccountsResponseStore] + * @property accountListConverterFactory factory to create [AccountListConverter] + * @property cardCryptoCurrencyFactory factory to create supported crypto currencies for a card + * +[REDACTED_AUTHOR] + */ +internal class WalletAccountListFlowFactory @Inject constructor( + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val accountListConverterFactory: AccountListConverter.Factory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + fun create(userWallet: UserWallet): Flow { + return if (userWallet.isMultiCurrency) { + createForMultiWallet(userWallet) + } else { + flowOf(createForSingleWallet(userWallet)) + } + } + + private fun createForMultiWallet(userWallet: UserWallet): Flow { + val converter by lazy { accountListConverterFactory.create(userWallet) } + + return accountsResponseStoreFactory.create(userWallet.walletId).data + .filterNotNull() + .distinctUntilChanged() + .map(converter::convert) + } + + private fun createForSingleWallet(userWallet: UserWallet): AccountList { + val isSingleWalletWithToken = userWallet.requireColdWallet().cardTypesResolver.isSingleWalletWithToken() + + val currencies = if (isSingleWalletWithToken) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = userWallet).toSet() + } else { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet).let(::setOf) + } + + return AccountList.empty(userWallet = userWallet, cryptoCurrencies = currencies) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt new file mode 100644 index 0000000000..3ca26b0198 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt @@ -0,0 +1,222 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultMultiAccountListProducerTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() + + private val producer = DefaultMultiAccountListProducer( + params = Unit, + userWalletsStore = userWalletsStore, + walletAccountListFlowFactory = walletAccountListFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + @AfterEach + fun tearDownEach() { + clearMocks(userWalletsStore, walletAccountListFlowFactory) + } + + @Test + fun produce() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = listOf(accountList) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow will updated if factoryFlow is updated`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(listOf(accountList)) + + // Act (second emission) + factoryFlow.value = updatedAccountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(listOf(updatedAccountList)) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is filtered the same response`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(listOf(accountList)) + + // Act (second emission) - the same status + factoryFlow.value = accountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(listOf(accountList)) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow returns empty list if factory throws exception`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val exception = RuntimeException("Converter error") + every { walletAccountListFlowFactory.create(userWallet) } throws exception + + // Act + val actual = producer.produceWithFallback().let(::getEmittedValues) + + // Assert + val expected = emptyList() + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = emptyFlow>() + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(exactly = 1) { userWalletsStore.userWallets } + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } + + @Test + fun `flow is empty if factory returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + every { walletAccountListFlowFactory.create(userWallet) } returns emptyFlow() + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if one of factoryFlow is empty`() = runTest { + // Arrange + val userWalletId2 = UserWalletId("012") + val userWallet2 = mockk { + every { this@mockk.walletId } returns userWalletId2 + } + + val userWalletsFlow = MutableStateFlow(listOf(userWallet, userWallet2)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + every { walletAccountListFlowFactory.create(userWallet2) } returns emptyFlow() + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWallet2) + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt new file mode 100644 index 0000000000..846e46bfea --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -0,0 +1,198 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSingleAccountListProducerTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + private val producer = DefaultSingleAccountListProducer( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + userWalletsStore = userWalletsStore, + walletAccountListFlowFactory = walletAccountListFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDownEach() { + clearMocks(userWalletsStore, walletAccountListFlowFactory) + } + + @Test + fun produce() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = accountList + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow will updated if factoryFlow is updated`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(accountList) + + // Act (second emission) + factoryFlow.value = updatedAccountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(updatedAccountList) + + coVerifyOrder { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is filtered the same response`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(accountList) + + // Act (second emission) - the same status + factoryFlow.value = accountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(accountList) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if factory throws exception`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val exception = RuntimeException("Converter error") + every { walletAccountListFlowFactory.create(userWallet) } throws exception + + // Act + val actual = producer.produceWithFallback().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = emptyFlow>() + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(exactly = 1) { userWalletsStore.userWallets } + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } + + @Test + fun `flow is empty if userWalletsFlow doesn't contains userWalletId from params`() = runTest { + // Arrange + val unknownId = UserWalletId("012") + val unknownWallet = mockk { + every { this@mockk.walletId } returns unknownId + } + + val userWalletsFlow = MutableStateFlow(listOf(unknownWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + } + + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt new file mode 100644 index 0000000000..c94ef86564 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt @@ -0,0 +1,148 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.account.converter.AccountListConverter +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WalletAccountListFlowFactoryTest { + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val accountListConverterFactory: AccountListConverter.Factory = mockk() + private val accountListConverter: AccountListConverter = mockk() + + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val factory = WalletAccountListFlowFactory( + accountsResponseStoreFactory = accountsResponseStoreFactory, + accountListConverterFactory = accountListConverterFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + @AfterEach + fun tearDownEach() { + clearMocks(accountListConverter) + + accountsResponseStoreFlow.value = null + } + + @Test + fun `create for multi wallet`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val accountsResponse = createGetWalletAccountsResponse(userWalletId) + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + accountsResponseStoreFlow.value = accountsResponse + + val accountList = AccountList.empty(userWallet) + every { accountListConverterFactory.create(userWallet) } returns accountListConverter + every { accountListConverter.convert(accountsResponse) } returns accountList + + // Act + val actual = factory.create(userWallet).let(::getEmittedValues) + + // Assert + val expected = accountList + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountListConverterFactory.create(userWallet) + accountListConverter.convert(accountsResponse) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(any()) + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(any()) + } + } + + @Test + fun `create for single wallet`() = runTest { + val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false) + + val currency = cryptoCurrencyFactory.ethereum + every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) } returns currency + + // Act + val actual = factory.create(userWallet).let(::getEmittedValues) + + // Assert + val expected = AccountList.empty(userWallet = userWallet, cryptoCurrencies = setOf(currency)) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = any()) + accountsResponseStoreFactory.create(any()) + accountsResponseStore.data + accountListConverterFactory.create(any()) + accountListConverter.convert(any()) + } + } + + @Test + fun `flow is created for single wallet with token`() = runTest { + val nodl = MockUserWalletFactory.createSingleWalletWithToken() + + val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() + every { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) + } returns currencies.toList() + + // Act + val actual = factory.create(nodl).let(::getEmittedValues) + + // Assert + val expected = AccountList.empty(userWallet = nodl, cryptoCurrencies = currencies) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(any()) + accountsResponseStoreFactory.create(any()) + accountsResponseStore.data + accountListConverterFactory.create(any()) + accountListConverter.convert(any()) + } + } +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt index b5b8ad9269..ceb7c2fc1c 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt @@ -7,13 +7,14 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.card.WalletData import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -159,7 +160,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { model: CreateTestModel.SingleWalletWithToken, ) = runTest { // Arrange - val userWallet = createSingleWalletWithToken() + val userWallet = MockUserWalletFactory.createSingleWalletWithToken() coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet @@ -284,7 +285,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), ), CreateCurrenciesForMultiWalletModel( - multiWallet = createSingleWalletWithToken(), + multiWallet = MockUserWalletFactory.createSingleWalletWithToken(), userTokensResponse = null, expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), ), @@ -456,7 +457,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { expected = Result.failure(IllegalArgumentException("Coin for the single currency card cannot be null")), ), CreateForSingleWalletWithTokenModel( - singleWalletWithToken = createSingleWalletWithToken(), + singleWalletWithToken = MockUserWalletFactory.createSingleWalletWithToken(), isPrimaryTokenExpected = true, expected = Result.success(listOf(ethereum)), ), @@ -522,33 +523,8 @@ internal class DefaultCardCryptoCurrencyFactoryTest { ) } - private fun createSingleWalletWithToken(): UserWallet.Cold { - return UserWallet.Cold( - name = "NODL", - walletId = UserWalletId("011"), - cardsInWallet = setOf(), - isMultiCurrency = false, - scanResponse = MockScanResponseFactory.create( - cardConfig = GenericCardConfig(maxWalletCount = 2), - derivedKeys = emptyMap(), - ).copy( - productType = ProductType.Note, - walletData = WalletData( - blockchain = "ETH", - token = WalletData.Token( - name = "Ethereum", - symbol = "ETH", - contractAddress = "0x", - decimals = 8, - ), - ), - ), - hasBackupError = false, - ) - } - private fun createPrimaryToken(blockchain: Blockchain): CryptoCurrency.Token { - val userWallet = createSingleWalletWithToken() + val userWallet = MockUserWalletFactory.createSingleWalletWithToken() return CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()).createToken( sdkToken = userWallet.scanResponse.cardTypesResolver.getPrimaryToken()!!, diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt new file mode 100644 index 0000000000..345e6897bc --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.core.flow.FlowProducer + +/** + * Produces a list of [AccountList]s for all user wallets. + * +[REDACTED_AUTHOR] + */ +interface MultiAccountListProducer : FlowProducer> { + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt new file mode 100644 index 0000000000..8d85130007 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Produces a list of [AccountList] for a specific user wallet. + * +[REDACTED_AUTHOR] + */ +interface SingleAccountListProducer : FlowProducer { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt new file mode 100644 index 0000000000..45989af478 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.core.flow.FlowCachingSupplier +import kotlinx.coroutines.flow.Flow + +/** + * Supplier that provides a list of [AccountList]s for all user wallets. + * +[REDACTED_AUTHOR] + */ +abstract class MultiAccountListSupplier( + override val factory: MultiAccountListProducer.Factory, + override val keyCreator: (Unit) -> String, +) : FlowCachingSupplier>() { + + operator fun invoke(): Flow> { + return super.invoke(params = Unit) + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt new file mode 100644 index 0000000000..cddb1d9182 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.core.flow.FlowCachingSupplier + +/** + * Supplier that provides a single [AccountList] for a specific user wallet. + * +[REDACTED_AUTHOR] + */ +abstract class SingleAccountListSupplier( + override val factory: SingleAccountListProducer.Factory, + override val keyCreator: (SingleAccountListProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file From ef39279cb9987549d7198552ddd3245264af8c8e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Sep 2025 14:32:40 +0400 Subject: [PATCH 112/132] Updated on 2026-08-14 --- .../common/extensions/CustomAssertsExt.kt | 25 +++++++++ .../tangem/screens/MainScreenPageObject.kt | 52 +++++++++++++++++-- .../screens/OrganizeTokensPageObject.kt | 22 +++++--- .../tangem/screens/TokenDetailsPageObject.kt | 7 ++- .../steps/SingleCurrencyCardScenario.kt | 35 +++++++++++++ .../tangem/tests/SingleCurrencyCardsScan.kt | 31 +++++++++++ .../marketprice/MarketPriceBlock.kt | 6 ++- .../components/transactions/TxHistoryTitle.kt | 8 ++- .../tangem/core/ui/test/MainScreenTestTags.kt | 2 + .../core/ui/test/MarketPriceBlockTestTags.kt | 6 +++ .../test/TransactionHistoryBlockTestTags.kt | 7 +++ .../wallet/ui/components/common/WalletCard.kt | 6 ++- 12 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/steps/SingleCurrencyCardScenario.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/SingleCurrencyCardsScan.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/MarketPriceBlockTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryBlockTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt new file mode 100644 index 0000000000..e1040b8ffc --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt @@ -0,0 +1,25 @@ +package com.tangem.common.extensions + +import io.github.kakaocup.compose.node.element.KNode + +fun assertElementDoesNotExist( + elementProvider: () -> KNode, + elementDescription: String, +) { + try { + elementProvider().assertExists() + throw AssertionError("$elementDescription should not exist but was found") + } catch (e: AssertionError) { + val isNotFoundError = e.message?.let { message -> + message.contains("No node found") || + message.contains("scrollable container") || + message.contains("There are no existing nodes") || + message.contains("There are no existing nodes for that selector") + } ?: false + if (isNotFoundError) { + return + } else { + throw e + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 4f14e268c2..f8abf2f869 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -7,10 +7,7 @@ import androidx.compose.ui.test.hasAnyAncestor import com.tangem.common.BaseTestCase import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.utils.LazyListItemNode -import com.tangem.core.ui.test.BaseButtonTestTags -import com.tangem.core.ui.test.MainScreenTestTags -import com.tangem.core.ui.test.NotificationTestTags -import com.tangem.core.ui.test.TokenElementsTestTags +import com.tangem.core.ui.test.* import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.feature.wallet.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen @@ -51,6 +48,45 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_buy)) } + val walletNameText: KNode = child { + hasTestTag(MainScreenTestTags.CARD_TITLE) + useUnmergedTree = true + } + + val walletImage: KNode = child { + hasTestTag(MainScreenTestTags.CARD_IMAGE) + useUnmergedTree = true + } + + val marketPriceBlock: KNode = child { + hasTestTag(MarketPriceBlockTestTags.BLOCK) + useUnmergedTree = true + } + + val marketPriceText: KNode = child { + hasTestTag(MarketPriceBlockTestTags.TEXT) + useUnmergedTree = true + } + + val transactionsExplorerIcon: KNode = child { + hasTestTag(TransactionHistoryBlockTestTags.EXPLORER_ICON) + useUnmergedTree = true + } + + val transactionsTitle: KNode = child { + hasTestTag(TransactionHistoryBlockTestTags.TITLE_TEXT) + hasText(getResourceString(R.string.common_transactions)) + useUnmergedTree = true + } + + fun transactionsExplorer(): KNode { + return child { + hasTestTag(TransactionHistoryBlockTestTags.EXPLORER_TEXT) + hasText(getResourceString(R.string.common_explorer)) + useUnmergedTree = true + } + } + val notificationContainer: KNode = child { hasTestTag(NotificationTestTags.CONTAINER) useUnmergedTree = true @@ -146,6 +182,14 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + fun organizeTokensButtonWithoutLazySearch(): KNode { + return child { + hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON) + hasText(getResourceString(R.string.organize_tokens_title)) + useUnmergedTree = true + } + } + fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { return lazyList.child { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt index f7a76181e8..1d5bd3418b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.utils.LazyListItemNode -import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.OrganizeTokensScreenTestTags +import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.feature.wallet.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen @@ -14,10 +14,10 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import androidx.compose.ui.test.hasText as withText -import androidx.compose.ui.test.hasTestTag as withTestTag -import androidx.compose.ui.test.hasAnySibling as withAnySibling import androidx.compose.ui.test.hasAnyChild as withAnyChild +import androidx.compose.ui.test.hasAnySibling as withAnySibling +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -25,10 +25,12 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi // region TopBar val title: KNode = child { hasText(getResourceString(R.string.organize_tokens_title)) + useUnmergedTree = true } private val topBarGroupButton: KNode = child { hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON) + useUnmergedTree = true } val groupButton: KNode = topBarGroupButton.child { @@ -100,10 +102,14 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi return lazyList.child { hasTestTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE) useUnmergedTree = true - hasParent(withTestTag(TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK) - .and(withAnySibling(withTestTag(TokenElementsTestTags.TOKEN_TITLE) - .and(withAnyChild(withText(tokenTitle)))) - ) + hasParent( + withTestTag(TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK) + .and( + withAnySibling( + withTestTag(TokenElementsTestTags.TOKEN_TITLE) + .and(withAnyChild(withText(tokenTitle))) + ) + ) ) } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 3b9fb1b195..3a0cd0962d 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -4,17 +4,17 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.test.TokenDetailsScreenTestTags -import com.tangem.features.tokendetails.impl.R import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import com.tangem.features.tokendetails.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode -import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasText as withText class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : @@ -156,7 +156,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasText(getResourceString(R.string.common_buy_currency, feeCurrencySymbol)) useUnmergedTree = true } - } internal fun BaseTestCase.onTokenDetailsScreen(function: TokenDetailsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/steps/SingleCurrencyCardScenario.kt b/app/src/androidTest/kotlin/com/tangem/steps/SingleCurrencyCardScenario.kt new file mode 100644 index 0000000000..f9c68f5096 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/steps/SingleCurrencyCardScenario.kt @@ -0,0 +1,35 @@ +package com.tangem.steps + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.assertElementDoesNotExist +import com.tangem.screens.onMainScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkSingleCurrencyMainScreen(cardBlockchain: String, cardTitle: String) { + step("Assert card title equal '$cardTitle'") { + onMainScreen { walletNameText.assertTextEquals(cardTitle) } + } + step("Assert 'Transactions' block is displayed") { + onMainScreen { transactionsExplorer().assertExists() } + } + step("Assert 'Transactions' title is displayer") { + onMainScreen { transactionsTitle.assertIsDisplayed() } + } + step("Assert 'Explorer Icon' is displayed") { + onMainScreen { transactionsExplorerIcon.assertIsDisplayed() } + } + step("Assert card image is displayed") { //TODO: Придумать нормальную проверку / реализовать скриншот-тестинг + onMainScreen { walletImage.assertIsDisplayed() } + } + step("Assert 'Market Price' on single card main screen is displayed") { + onMainScreen { marketPriceBlock.assertIsDisplayed() } + } + step("Assert 'Market Price' title equals $cardBlockchain Market Price") { + onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") } + } + step("Assert 'Organize tokens' button is not displayed") { + onMainScreen { + assertElementDoesNotExist({ organizeTokensButtonWithoutLazySearch() }, "Organize tokens button") + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SingleCurrencyCardsScan.kt b/app/src/androidTest/kotlin/com/tangem/tests/SingleCurrencyCardsScan.kt new file mode 100644 index 0000000000..34d745794f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/SingleCurrencyCardsScan.kt @@ -0,0 +1,31 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.openMainScreen +import com.tangem.steps.checkSingleCurrencyMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SingleCurrencyCardsScan : BaseTestCase() { + + @AllureId("868") + @DisplayName("Scan: Scanning single-currency cards") + @Test + fun singleTokenNoteScanTest() { + val cardBlockchain = "DOGE" + val cardType: ProductType = ProductType.Note + + setupHooks().run { + step("Open 'Main Screen' on ${cardType.name} card") { + openMainScreen(cardType) + } + step("Check 'Main' screen for ${cardType.name} $cardBlockchain card") { + checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardType.name) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt index 204f816c64..92785ec693 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -20,6 +21,7 @@ import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.MarketPriceBlockTestTags import com.tangem.utils.StringsSigns.DASH_SIGN /** @@ -45,7 +47,8 @@ fun MarketPriceBlock(state: MarketPriceBlockState, modifier: Modifier = Modifier .fillMaxWidth() .heightIn(min = TangemTheme.dimens.size72) .padding(all = TangemTheme.dimens.spacing12) - .onSizeChanged { rootWidth = it.width }, + .onSizeChanged { rootWidth = it.width } + .testTag(MarketPriceBlockTestTags.BLOCK), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), horizontalAlignment = Alignment.Start, ) { @@ -64,6 +67,7 @@ private fun Title(currencyName: String, modifier: Modifier = Modifier) { text = stringResourceSafe(id = R.string.wallet_marketplace_block_title, currencyName), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(MarketPriceBlockTestTags.TEXT), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt index f7cb3263ce..eec1924146 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryTitle.kt @@ -9,12 +9,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionHistoryBlockTestTags /** * Transactions block title @@ -38,10 +40,13 @@ fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) { text = stringResourceSafe(id = R.string.common_transactions), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(TransactionHistoryBlockTestTags.TITLE_TEXT), ) Row( - modifier = Modifier.clickable(onClick = onExploreClick), + modifier = Modifier + .clickable(onClick = onExploreClick) + .testTag(TransactionHistoryBlockTestTags.EXPLORER_ICON), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2), ) { @@ -55,6 +60,7 @@ fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) { text = stringResourceSafe(id = R.string.common_explorer), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(TransactionHistoryBlockTestTags.EXPLORER_TEXT), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt index 117cfefc69..cb1b1e1edf 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt @@ -8,6 +8,8 @@ object MainScreenTestTags { const val WALLET_LIST_ITEM = "MAIN_SCREEN_WALLET_LIST_ITEM" const val ORGANIZE_TOKENS_BUTTON = "MAIN_SCREEN_ORGANIZE_TOKENS_BUTTON" const val MULTI_CURRENCY_ACTION_BUTTON = "MAIN_SCREEN_MULTI_CURRENCY_ACTION_BUTTON" + const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE" + const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE" const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE" const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM" diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MarketPriceBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MarketPriceBlockTestTags.kt new file mode 100644 index 0000000000..c271c61cc3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MarketPriceBlockTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object MarketPriceBlockTestTags { + const val BLOCK = "MAIN_SCREEN_MARKET_PRICE_BLOCK" + const val TEXT = "MAIN_SCREEN_MARKET_PRICE_TEXT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryBlockTestTags.kt new file mode 100644 index 0000000000..4a031ddd39 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryBlockTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object TransactionHistoryBlockTestTags { + const val TITLE_TEXT = "TRANSACTION_HISTORY_TITLE_TEXT" + const val EXPLORER_TEXT = "TRANSACTION_HISTORY_EXPLORER_TEXT" + const val EXPLORER_ICON = "TRANSACTION_HISTORY_EXPLORER_ICON" +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index d953f8a8ba..08f52280ea 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -261,7 +261,7 @@ private fun MenuItem(text: TextReference, imageVector: ImageVector, onClick: () private fun TitleText(text: String, modifier: Modifier = Modifier) { Text( text = text, - modifier = modifier, + modifier = modifier.testTag(MainScreenTestTags.CARD_TITLE), color = TangemTheme.colors.text.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -375,7 +375,9 @@ private fun Image(@DrawableRes id: Int?, modifier: Modifier = Modifier) { Image( painter = painterResource(id = imageRes), contentDescription = null, - modifier = Modifier.width(width = TangemTheme.dimens.size120), + modifier = Modifier + .width(width = TangemTheme.dimens.size120) + .testTag(MainScreenTestTags.CARD_IMAGE), contentScale = ContentScale.FillWidth, ) } From 99400590a658b61fa44739a00894dbf39d51feaa Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Sep 2025 16:23:13 +0500 Subject: [PATCH 113/132] Updated on 2026-08-14 --- .../tangem/common/constants/TestConstants.kt | 2 + .../common/extensions/BaseTestCaseExt.kt | 49 +++- .../com/tangem/common/extensions/KNode.kt | 3 +- .../screens/BaseBottomSheetPageObject.kt | 22 ++ .../com/tangem/screens/DialogPageObject.kt | 10 + .../com/tangem/screens/MarketsPageObject.kt | 60 +++++ .../com/tangem/screens/SearchBarPageObject.kt | 19 ++ .../tangem/screens/SelectCountryPageObject.kt | 6 +- .../kotlin/com/tangem/tests/HideTokenTest.kt | 4 +- .../com/tangem/tests/OrganizeTokensTest.kt | 33 ++- .../com/tangem/tests/TermsOfServiceTest.kt | 14 +- .../com/tangem/tests/WalletConnectTest.kt | 7 +- .../tests/balance/TotalBalanceUpdateTest.kt | 209 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 7 + .../tangem/core/ui/components/SettingsRow.kt | 4 +- .../tangem/core/ui/components/TangemSwitch.kt | 4 +- .../core/ui/components/fields/SearchBar.kt | 4 +- .../core/ui/test/BaseBottomSheetTestTags.kt | 5 + .../core/ui/test/BaseSearchBarTestTags.kt | 5 + .../tangem/core/ui/test/MarketsTestTags.kt | 7 + .../test/SelectCountryBottomSheetTestTags.kt | 1 - .../impl/ui/AddToPortfolioBottomSheet.kt | 6 +- .../impl/ui/components/MarketsListItem.kt | 5 +- .../ui/components/MarketsListLazyColumn.kt | 4 +- 24 files changed, 443 insertions(+), 47 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/SearchBarPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/BaseSearchBarTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index a985597ee8..dc2be15e46 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -6,4 +6,6 @@ object TestConstants { const val RECIPIENT_ADDRESS = "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0" const val WAIT_UNTIL_TIMEOUT = 20_000L + + const val MARKETS_MAIN_NETWORK_SUFFIX = "MAIN" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt index 72637225d9..daace0f71a 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/BaseTestCaseExt.kt @@ -1,11 +1,15 @@ package com.tangem.common.extensions +import androidx.test.uiautomator.By import com.tangem.common.BaseTestCase +import com.tangem.wallet.R +import io.github.kakaocup.kakao.common.utilities.getResourceString -fun BaseTestCase.swipeUp( - startHeightRatio: Float = 0.8f, - endHeightRatio: Float = 0.03f, - steps: Int = 15 +fun BaseTestCase.swipeVertical( + direction: SwipeDirection, + startHeightRatio: Float = if (direction == SwipeDirection.UP) 0.8f else 0.03f, + endHeightRatio: Float = if (direction == SwipeDirection.UP) 0.03f else 0.8f, + steps: Int = 15, ) { device.uiDevice.swipe( device.uiDevice.displayWidth / 2, @@ -14,4 +18,41 @@ fun BaseTestCase.swipeUp( (device.uiDevice.displayHeight * endHeightRatio).toInt(), steps ) +} + +fun BaseTestCase.pullToRefresh() { + swipeVertical( + direction = SwipeDirection.DOWN, + startHeightRatio = 0.2f, + endHeightRatio = 0.8f, + steps = 2000 + ) +} + +fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) { + val searchBarText = device.uiDevice + .findObject(By.textContains(getResourceString(R.string.markets_search_header_title))) + val bounds = searchBarText.visibleBounds + + val centerX = bounds.centerX() + val startY = bounds.centerY() + val endY = when (direction) { + SwipeDirection.UP -> 50 + SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100 + } + + device.uiDevice.swipe(centerX, startY, centerX, endY, 100) +} + +fun BaseTestCase.openTheAppFromRecents() { + device.uiDevice.pressRecentApps() + + val centerX = device.uiDevice.displayWidth / 2 + val centerY = device.uiDevice.displayHeight / 3 + device.uiDevice.click(centerX, centerY) + device.uiDevice.click(centerX, centerY) +} + +enum class SwipeDirection { + UP, DOWN } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt index a7baa714a4..7c275607c9 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -5,4 +5,5 @@ import io.github.kakaocup.compose.node.element.KNode fun KNode.clickWithAssertion() { assertIsDisplayed() performClick() -} \ No newline at end of file +} + diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt new file mode 100644 index 0000000000..d40a96c38a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt @@ -0,0 +1,22 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class BaseBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val hideButton: KNode = child { + hasText(getResourceString(R.string.token_details_hide_token)) + hasTestTag(BaseBottomSheetTestTags.ACTION_TITLE) + } +} + +internal fun BaseTestCase.onBottomSheet(function: BaseBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index e60f497bc3..9543830d26 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -31,6 +31,16 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_confirm)) } + + val continueButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_continue)) + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } } internal fun BaseTestCase.onDialog(function: DialogPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt new file mode 100644 index 0000000000..4f6707a071 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt @@ -0,0 +1,60 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.MarketsTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import com.tangem.features.onramp.impl.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + private val lazyList = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(MarketsTestTags.TOKENS_LIST) }, + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> + SemanticsMatcher.expectValue( + LazyListItemPositionSemantics, + position + ) + } + ) + + val addToPortfolioButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_add_to_portfolio)) + useUnmergedTree = true + } + + val mainNetworkSwitch: KNode = child { + hasAnyDescendant(withText(MARKETS_MAIN_NETWORK_SUFFIX)) + useUnmergedTree = true + }.child { hasTestTag(MarketsTestTags.ADD_TO_PORTFOLIO_SWITCH) } + + val topBarBackButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + fun tokenWithTitle(title: String): KNode { + return lazyList.child { + hasText(title) + useUnmergedTree = true + } + } +} + +internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SearchBarPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SearchBarPageObject.kt new file mode 100644 index 0000000000..45e3fae83b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SearchBarPageObject.kt @@ -0,0 +1,19 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseSearchBarTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode + +class SearchBarPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val searchField: KNode = child { + hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) + } +} + +internal fun BaseTestCase.onSearchBar(function: SearchBarPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt index b3a6a9d573..c3178f7435 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SelectCountryPageObject.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.test.BaseSearchBarTestTags import com.tangem.core.ui.test.SelectCountryBottomSheetTestTags import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.features.onramp.impl.R @@ -12,9 +13,8 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasTestTag as withTestTag - +import androidx.compose.ui.test.hasText as withText class SelectCountryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -33,7 +33,7 @@ class SelectCountryPageObject(semanticsProvider: SemanticsNodeInteractionsProvid ) val searchBar: KNode = child { - hasTestTag(SelectCountryBottomSheetTestTags.SEARCH_BAR) + hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt index 9e1efb911a..fa4519eede 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt @@ -3,9 +3,9 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion -import com.tangem.screens.* import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -33,7 +33,7 @@ class HideTokenTest : BaseTestCase() { step("Assert 'Token details screen' open") { onTokenDetailsScreen { screenContainer.assertIsDisplayed() } } - step("Click 'More button'") { + step("Click 'More' button") { onTokenDetailsTopBar { moreButton.clickWithAssertion() } } step("Click 'Hide token' button") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index e0f1d97826..76d0d0f0f6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -3,12 +3,13 @@ package com.tangem.tests import androidx.compose.ui.test.onAllNodesWithText import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeUp -import com.tangem.screens.onMainScreen -import com.tangem.screens.onOrganizeTokensScreen +import com.tangem.common.extensions.swipeVertical import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import com.tangem.screens.onOrganizeTokensScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -28,12 +29,12 @@ class OrganizeTokensTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { + step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -57,8 +58,8 @@ class OrganizeTokensTest : BaseTestCase() { onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -106,8 +107,8 @@ class OrganizeTokensTest : BaseTestCase() { } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -133,7 +134,7 @@ class OrganizeTokensTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } - step("Click on 'Synchronize addresses' button" ) { + step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } step("Check positions of tokens on 'Main Screen'") { @@ -143,8 +144,8 @@ class OrganizeTokensTest : BaseTestCase() { } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -194,8 +195,8 @@ class OrganizeTokensTest : BaseTestCase() { } } step("Swipe to 'Organize tokens' button") { - swipeUp() - swipeUp() + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) } step("Click 'Organize tokens' button") { onMainScreen { organizeTokensButton().clickWithAssertion() } @@ -234,6 +235,4 @@ class OrganizeTokensTest : BaseTestCase() { } } } - - } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt index 7f729f1c3c..379b5b898e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt @@ -2,8 +2,9 @@ package com.tangem.tests import androidx.test.InstrumentationRegistry.getTargetContext import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeUp +import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onDisclaimerScreen import com.tangem.screens.onStoriesScreen import dagger.hilt.android.testing.HiltAndroidTest @@ -37,7 +38,8 @@ class TermsOfServiceTest : BaseTestCase() { step("Assert 'Stories' screen is opened") { onStoriesScreen { scanButton.assertIsDisplayed() - orderButton.assertIsDisplayed()} + orderButton.assertIsDisplayed() + } } } } @@ -67,7 +69,7 @@ class TermsOfServiceTest : BaseTestCase() { device.uiDevice.pressRecentApps() } step("Stop app by swipe") { - swipeUp(startHeightRatio = 0.5f) + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.5f) } step("Launch app") { device.apps.launch(packageName) @@ -90,7 +92,7 @@ class TermsOfServiceTest : BaseTestCase() { device.uiDevice.pressRecentApps() } step("Stop app by swipe") { - swipeUp(startHeightRatio = 0.5f) + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.5f) } step("Launch app") { device.apps.launch(packageName) @@ -98,9 +100,9 @@ class TermsOfServiceTest : BaseTestCase() { step("Assert 'Stories' screen is opened") { onStoriesScreen { scanButton.assertIsDisplayed() - orderButton.assertIsDisplayed()} + orderButton.assertIsDisplayed() + } } } } - } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt index b0f6b79e78..db5e364f20 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -2,11 +2,12 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeUp +import com.tangem.common.extensions.swipeVertical import com.tangem.common.utils.getWcUri -import com.tangem.screens.* import com.tangem.scenarios.* +import com.tangem.screens.* import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -140,7 +141,7 @@ class WalletConnectTest : BaseTestCase() { device.uiDevice.pressRecentApps() } step("Stop app by swipe") { - swipeUp(startHeightRatio = 0.8f) + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.8f) } step("Create WC session buy deeplink") { openAppByDeepLink(deepLinkUri) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt new file mode 100644 index 0000000000..1c7fd38068 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -0,0 +1,209 @@ +package com.tangem.tests.balance + +import androidx.compose.ui.test.longClick +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.* +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TotalBalanceUpdateTest : BaseTestCase() { + + @Test + @AllureId("150") + @DisplayName("Total balance: check balance update after pull to refresh") + fun afterPullToRefreshTest() { + val scenarioName = "eth_network_balance" + val scenarioState = "Empty" + val updatedBalance = "$763.55" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Perform pull to refresh") { + pullToRefresh() + } + step("Assert Total balance is updated from $TOTAL_BALANCE to $updatedBalance") { + onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } + } + } + } + + @Test + @AllureId("3997") + @DisplayName("Total balance: check balance update after token added") + fun afterAddTokenTest() { + val tokenTitle = "XRP" + val scenarioName = "quotes_api" + val scenarioState = "Ripple" + val updatedBalance = "$3,307.18" + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Open 'Markets screen'") { + swipeMarketsBlock(SwipeDirection.UP) + composeTestRule.waitForIdle() + } + step("Click on $tokenTitle token") { + onMarketsScreen { tokenWithTitle(tokenTitle).clickWithAssertion() } + } + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Click on 'Add to portfolio' button") { + onMarketsScreen { addToPortfolioButton.clickWithAssertion() } + } + step("Toggle the main network switch") { + onMarketsScreen { mainNetworkSwitch.performClick() } + } + step("Click on 'Continue' button") { + onDialog { continueButton.clickWithAssertion() } + } + step("Assert 'Continue' is not displayed") { + onDialog { continueButton.assertIsNotDisplayed() } + } + step("Go back to 'Markets: tokens list'") { + composeTestRule.waitForIdle() + onMarketsScreen { topBarBackButton.clickWithAssertion() } + } + step("Close 'Markets screen'") { + onSearchBar { searchField.assertIsDisplayed() } + swipeMarketsBlock(SwipeDirection.DOWN) + } + step("Assert $updatedBalance is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } + } + } + } + + @Test + @AllureId("4000") + @DisplayName("Total balance: check balance update after collapse/expand") + fun afterCollapseAndExpandTest() { + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Press 'Home' to collapse the app") { + device.uiDevice.pressHome() + } + step("Open the app from recent apps") { + openTheAppFromRecents() + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + } + } + + @Test + @AllureId("3998") + @DisplayName("Total balance: check balance update after hide token") + fun afterHideTokenTest() { + val tokenTitle = "Polygon" + val updatedBalance = "$3,114.34" + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Long click on token with name: '$tokenTitle'") { + composeTestRule.waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Click 'Hide token' button") { + onBottomSheet { hideButton.clickWithAssertion() } + } + step("Click 'Hide' button in dialog") { + onDialog { + dialogContainer.assertIsDisplayed() + okButton.clickWithAssertion() + } + } + step("Assert $updatedBalance is displayed in total balance") { + swipeVertical(SwipeDirection.DOWN) + onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } + } + } + } + + @Test + @AllureId("4001") + @DisplayName("Total balance: check balance update after navigation") + fun afterNavigationTest() { + val tokenTitle = "Polygon" + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(TOTAL_BALANCE) + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + step("Click on token with name: '$tokenTitle'") { + onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } + } + step("Assert 'Token details screen' open") { + onTokenDetailsScreen { screenContainer.assertIsDisplayed() } + } + step("Click 'More' button") { + onTokenDetailsTopBar { backButton.clickWithAssertion() } + } + step("Assert $TOTAL_BALANCE is displayed in total balance") { + onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index f562753a55..1db7cd1bb9 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -198,6 +198,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // xrp + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt index b1b8fa87b0..b49c5737ad 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt @@ -11,8 +11,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.BaseBottomSheetTestTags /** * [Show in Figma](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=281-248&mode=design&t=bXqehWPHyATKcZEW-4) @@ -38,7 +40,7 @@ fun SimpleSettingsRow( onItemsClick() } }, - ), + ).testTag(BaseBottomSheetTestTags.ACTION_TITLE), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt index 972f347106..de1f9aa454 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt @@ -15,10 +15,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.MarketsTestTags @Suppress("MagicNumber") @Composable @@ -56,7 +58,7 @@ fun TangemSwitch( onClick = { onCheckedChange(!checked) }, - ), + ).testTag(MarketsTestTags.ADD_TO_PORTFOLIO_SWITCH), ) { BoxWithConstraints( modifier = Modifier diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt index 12847efe53..94f6b64e78 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt @@ -38,7 +38,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.SelectCountryBottomSheetTestTags +import com.tangem.core.ui.test.BaseSearchBarTestTags @Composable fun SearchBar( @@ -64,7 +64,7 @@ fun SearchBar( } } .focusRequester(focusRequester) - .testTag(SelectCountryBottomSheetTestTags.SEARCH_BAR), + .testTag(BaseSearchBarTestTags.SEARCH_BAR), enabled = enabled, value = state.query, onValueChange = state.onQueryChange, diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt new file mode 100644 index 0000000000..2604a7ad0e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object BaseBottomSheetTestTags { + const val ACTION_TITLE = "BASE_BOTTOM_SHEET_ACTION_TITLE" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseSearchBarTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseSearchBarTestTags.kt new file mode 100644 index 0000000000..ad2b165837 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseSearchBarTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object BaseSearchBarTestTags { + const val SEARCH_BAR = "BASE_SEARCH_BAR" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt new file mode 100644 index 0000000000..0721a092cd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object MarketsTestTags { + const val TOKENS_LIST = "MARKETS_TOKENS_LIST" + const val TOKENS_LIST_ITEM = "MARKETS_TOKENS_LIST_ITEM" + const val ADD_TO_PORTFOLIO_SWITCH = "MARKETS_ADD_TO_PORTFOLIO_SWITCH" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt index 6a2d859e19..acb5cfa60f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SelectCountryBottomSheetTestTags.kt @@ -5,7 +5,6 @@ object SelectCountryBottomSheetTestTags { const val LAZY_LIST = "SELECT_COUNTRY_BOTTOM_SHEET_LAZY_LIST" const val COUNTRY_ITEM = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_ITEM" const val UNAVAILABLE_COUNTRY_ITEM = "SELECT_COUNTRY_BOTTOM_SHEET_UNAVAILABLE_COUNTRY_ITEM" - const val SEARCH_BAR = "SELECT_COUNTRY_BOTTOM_SHEET_SEARCH_BAR" const val COUNTRY_ICON = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_ICON" const val UNAVAILABLE_COUNTRY_ICON = "SELECT_COUNTRY_BOTTOM_SHEET_UNAVAILABLE_COUNTRY_ICON" const val COUNTRY_NAME = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_NAME" diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt index fdfbb03db2..c12a36531c 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -26,8 +26,8 @@ import com.tangem.common.ui.userwallet.UserWalletItem import com.tangem.core.ui.components.* import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.block.information.InformationBlock -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -228,9 +228,7 @@ private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifi isLastItem = index == state.networks.lastIndex, content = { BlockchainRow( - modifier = Modifier.padding( - end = TangemTheme.dimens.spacing4, - ), + modifier = Modifier.padding(end = TangemTheme.dimens.spacing4), model = network, action = { TangemSwitch( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index 01bcc834fd..e0daa6f742 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -28,6 +29,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.MarketsTestTags import com.tangem.core.ui.windowsize.WindowSizeType import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider @@ -41,7 +43,8 @@ internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modi modifier = modifier .fillMaxWidth() .clip(RectangleShape) - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .testTag(MarketsTestTags.TOKENS_LIST_ITEM), model = model, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index 91029b1f71..bc0f3aea6d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -18,6 +19,7 @@ import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.MarketsTestTags import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM @@ -65,7 +67,7 @@ internal fun MarketsListLazyColumn( } } else { LazyColumn( - modifier = modifier, + modifier = modifier.testTag(MarketsTestTags.TOKENS_LIST), state = lazyListState, contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = true, From 3b9107f73f824bce7add1dbd85136d30bcf55559 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Sep 2025 15:16:42 +0300 Subject: [PATCH 114/132] Updated on 2026-08-14 --- app/build.gradle.kts | 1 + app/proguard-rules.pro | 4 +++ app/src/main/AndroidManifest.xml | 3 +- .../java/com/tangem/tap/TangemApplication.kt | 2 ++ .../appsflyer/AppsFlyerAnalyticsClient.kt | 26 +++++++++++++++ .../appsflyer/AppsFlyerAnalyticsHandler.kt | 32 +++++++++++++++++++ .../handlers/appsflyer/AppsFlyerLogClient.kt | 15 +++++++++ .../main/res/xml/data_extraction_rules.xml | 13 ++++++++ .../config/environment/EnvironmentConfig.kt | 2 ++ .../converter/EnvironmentConfigConverter.kt | 2 ++ .../models/EnvironmentConfigModel.kt | 9 ++++++ .../com/tangem/domain/common/LogConfig.kt | 1 + gradle/dependencies.toml | 2 ++ 13 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt create mode 100644 app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt create mode 100644 app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerLogClient.kt create mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cfb020cedd..a046ec5c66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -354,6 +354,7 @@ dependencies { implementation(deps.coil.gif) implementation(deps.coil.svg) implementation(deps.amplitude) + implementation(deps.appsflyer) implementation(deps.spongecastle.core) implementation(deps.lottie) implementation(deps.compose.accompanist.appCompatTheme) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4e36894e88..5c7c51645b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -213,3 +213,7 @@ -keep class **.R$* { ; } + +# appsflyer +-keep class com.appsflyer.** { *; } +-keep class kotlin.jvm.internal.** { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 87d3840efe..ee754f78cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ + tools:replace="android:allowBackup, android:dataExtractionRules, android:fullBackupContent, android:label, android:largeHeap">