diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b36d773b95..ac8b4dfa53 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,8 @@ dependencies { implementation(projects.domain.promo) implementation(projects.domain.promo.models) implementation(projects.domain.networks) + implementation(projects.domain.quotes) + implementation(projects.domain.notifications) implementation(projects.common) implementation(projects.common.routing) @@ -152,6 +154,7 @@ dependencies { implementation(projects.data.nft) implementation(projects.data.onramp) implementation(projects.data.networks) + implementation(projects.data.quotes) /** Features */ implementation(projects.features.onboarding) @@ -273,6 +276,7 @@ dependencies { implementation(deps.reKotlin) implementation(deps.zxing.qrCore) implementation(deps.coil) + implementation(deps.coil.gif) implementation(deps.amplitude) implementation(deps.kotsonGson) implementation(deps.spongecastle.core) @@ -318,6 +322,7 @@ dependencies { /** Chucker */ debugImplementation(deps.chucker) mockedImplementation(deps.chuckerStub) + externalImplementation(deps.chuckerStub) internalImplementation(deps.chuckerStub) releaseImplementation(deps.chuckerStub) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 29f73d909e..7a607593e3 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 29f73d909e14cb3760118b197b1929a6088bc40e +Subproject commit 7a607593e34f0977474613f077532215eaf0ffe6 diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 4cc52e4409..bb4aa08278 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -1,6 +1,9 @@ package com.tangem.tap import android.app.Application +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import coil.ImageLoader @@ -71,9 +74,7 @@ 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.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import org.rekotlin.Store import kotlin.collections.set import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository @@ -228,12 +229,32 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat // endregion + private val appScope = MainScope() + override fun onCreate() { + enableStrictModeInDebug() super.onCreate() - init() + } - updateLogFiles() + private fun enableStrictModeInDebug() { + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectAll() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + .build(), + ) + } } private fun updateLogFiles() { @@ -260,18 +281,31 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat foregroundActivityObserver = ForegroundActivityObserver() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) - // TODO: Try to performance and user experience. - // [REDACTED_JIRA] + // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them. runBlocking { awaitAll( - async { featureTogglesManager.init() }, - async { excludedBlockchainsManager.init() }, - async { initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) }, + async { + featureTogglesManager.init() + }, + async { + excludedBlockchainsManager.init() + }, ) } - loadNativeLibraries() + appScope.launch { + initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) + launch(Dispatchers.IO) { + loadNativeLibraries() + walletConnect2Repository.init( + projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId, + ) + updateLogFiles() + } + } + ExceptionHandler.append(blockchainExceptionHandler) + if (LogConfig.network.blockchainSdkNetwork) { BlockchainSdkRetrofitBuilder.interceptors = listOf( createNetworkLoggingInterceptor(), @@ -287,9 +321,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat appPreferencesStore = appPreferencesStore, dispatchers = dispatchers, ) - appStateHolder.mainStore = store - walletConnect2Repository.init(projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId) + appStateHolder.mainStore = store } private fun createReduxStore(): Store { diff --git a/app/src/main/java/com/tangem/tap/common/images/Coil.kt b/app/src/main/java/com/tangem/tap/common/images/Coil.kt index 4b269bdca4..28336b233f 100644 --- a/app/src/main/java/com/tangem/tap/common/images/Coil.kt +++ b/app/src/main/java/com/tangem/tap/common/images/Coil.kt @@ -1,8 +1,11 @@ package com.tangem.tap.common.images import android.content.Context +import android.os.Build import android.util.Log import coil.ImageLoader +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder import coil.memory.MemoryCache import coil.request.CachePolicy import coil.util.Logger @@ -25,6 +28,15 @@ fun createCoilImageLoader(context: Context, logEnabled: Boolean = false): ImageL .build() } } + .components { + // According to Coil ImageDecoder API is faster and supports animated WebP and HEIF + // https://coil-kt.github.io/coil/gifs/ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } .memoryCachePolicy(CachePolicy.ENABLED) .memoryCache { MemoryCache.Builder(context) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt index a6128ddfe6..9859e3e4be 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt @@ -20,18 +20,22 @@ internal class DefaultVisaAuthTokenStorage @Inject constructor( private val dispatcherProvider: CoroutineDispatcherProvider, ) : VisaAuthTokenStorage { - private val secureStorage = AndroidSecureStorage( - preferences = SecureStorage.createEncryptedSharedPreferences( - context = applicationContext, - storageName = "visa_auth_storage", - ), - ) + private val secureStorage by lazy { + AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = "visa_auth_storage", + ), + ) + } - private val moshi = Moshi.Builder() - .add(KotlinJsonAdapterFactory()) - .build() + private val moshi by lazy { + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + } - private val tokensAdapter = moshi.adapter(VisaAuthTokens::class.java) + private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } override suspend fun store(cardId: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) { val json = tokensAdapter.toJson(tokens) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt index f752d10dea..b87d3fbfdf 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt @@ -20,12 +20,14 @@ class DefaultVisaOTPStorage @Inject constructor( private val dispatcherProvider: CoroutineDispatcherProvider, ) : VisaOTPStorage { - private val secureStorage = AndroidSecureStorage( - preferences = SecureStorage.createEncryptedSharedPreferences( - context = applicationContext, - storageName = "visa_otp_storage", - ), - ) + private val secureStorage by lazy { + AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = "visa_otp_storage", + ), + ) + } override suspend fun saveOTP(cardId: String, data: VisaOtpData) = withContext(dispatcherProvider.io) { secureStorage.store(data.rootOTP, VISA_ROOT_OTP_KEY_PREFIX + cardId) 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 9870501655..ae906c4a76 100644 --- a/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt +++ b/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt @@ -24,7 +24,7 @@ internal class RuntimeUserWalletsStore( return userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == key } } - override suspend fun getSyncStrict(key: UserWalletId): UserWallet { + override fun getSyncStrict(key: UserWalletId): UserWallet { return requireNotNull(getSyncOrNull(key)) { "Unable to find user wallet with provided ID: $key" } } diff --git a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt index f6467cc073..07f1c7e97f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt @@ -5,6 +5,7 @@ import com.tangem.domain.managetokens.* import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.managetokens.repository.ManageTokensRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -73,6 +74,7 @@ internal object ManageTokensDomainModule { stakingRepository: StakingRepository, quotesRepository: QuotesRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): SaveManagedTokensUseCase { return SaveManagedTokensUseCase( @@ -84,6 +86,7 @@ internal object ManageTokensDomainModule { stakingRepository = stakingRepository, quotesRepository = quotesRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 3a568db5e6..b6efd9bd4f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -5,6 +5,8 @@ import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.markets.* import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -49,8 +51,16 @@ object MarketsDomainModule { @Provides @Singleton - fun provideGetTokenQuotesUseCase(quotesRepository: QuotesRepository): GetCurrencyQuotesUseCase { - return GetCurrencyQuotesUseCase(quotesRepository = quotesRepository) + fun provideGetTokenQuotesUseCase( + quotesRepository: QuotesRepository, + singleQuoteSupplier: SingleQuoteSupplier, + tokensFeatureToggles: TokensFeatureToggles, + ): GetCurrencyQuotesUseCase { + return GetCurrencyQuotesUseCase( + quotesRepository = quotesRepository, + singleQuoteSupplier = singleQuoteSupplier, + tokensFeatureToggles = tokensFeatureToggles, + ) } @Provides @@ -63,6 +73,7 @@ object MarketsDomainModule { stakingRepository: StakingRepository, quotesRepository: QuotesRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): SaveMarketTokensUseCase { return SaveMarketTokensUseCase( @@ -73,6 +84,7 @@ object MarketsDomainModule { stakingRepository = stakingRepository, quotesRepository = quotesRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } 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 a8cb6b030d..0578e95316 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 @@ -35,6 +35,16 @@ internal object NFTDomainModule { nftRepository = nftRepository, ) + @Provides + @Singleton + fun providesRefreshAllUseCase( + currenciesRepository: CurrenciesRepository, + nftRepository: NFTRepository, + ): RefreshAllNFTUseCase = RefreshAllNFTUseCase( + currenciesRepository = currenciesRepository, + nftRepository = nftRepository, + ) + @Provides @Singleton fun providesFetchNFTCollectionAssetsUseCase(nftRepository: NFTRepository): FetchNFTCollectionAssetsUseCase = @@ -47,7 +57,7 @@ internal object NFTDomainModule { fun providesGetNFTAvailableNetworksUseCase( nftRepository: NFTRepository, currenciesRepository: CurrenciesRepository, - ): GetNFTAvailableNetworksUseCase = GetNFTAvailableNetworksUseCase( + ): GetNFTNetworksUseCase = GetNFTNetworksUseCase( currenciesRepository = currenciesRepository, nftRepository = nftRepository, ) @@ -66,4 +76,11 @@ internal object NFTDomainModule { GetNFTNetworkStatusUseCase( networksRepository = networksRepository, ) + + @Provides + @Singleton + fun providesGetNFTExploreUrlUseCase(nftRepository: NFTRepository): GetNFTExploreUrlUseCase = + GetNFTExploreUrlUseCase( + nftRepository = nftRepository, + ) } \ No newline at end of file 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 new file mode 100644 index 0000000000..caad0bce3a --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/NotificationsDomainModule.kt @@ -0,0 +1,21 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.notifications.GetApplicationIdUseCase +import com.tangem.domain.notifications.repository.NotificationsRepository +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 NotificationsDomainModule { + + @Provides + @Singleton + fun providesGetApplicationIdUseCase(notificationsRepository: NotificationsRepository): GetApplicationIdUseCase = + GetApplicationIdUseCase( + notificationsRepository = notificationsRepository, + ) +} \ No newline at end of file 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 237b83807e..075ab6d9f7 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 @@ -7,7 +7,11 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.* import com.tangem.domain.tokens.operations.BaseCurrenciesStatusesOperations import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations @@ -35,6 +39,7 @@ internal object TokensDomainModule { stakingRepository: StakingRepository, quotesRepository: QuotesRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): AddCryptoCurrenciesUseCase { return AddCryptoCurrenciesUseCase( @@ -43,6 +48,7 @@ internal object TokensDomainModule { stakingRepository = stakingRepository, quotesRepository = quotesRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -55,6 +61,7 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): FetchTokenListUseCase { return FetchTokenListUseCase( @@ -63,6 +70,7 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, stakingRepository = stakingRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -168,6 +176,7 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): FetchCurrencyStatusUseCase { return FetchCurrencyStatusUseCase( @@ -176,6 +185,7 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, stakingRepository = stakingRepository, singleNetworkStatusFetcher = singleNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -188,6 +198,7 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): FetchCardTokenListUseCase { return FetchCardTokenListUseCase( @@ -196,6 +207,7 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, stakingRepository = stakingRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -364,10 +376,14 @@ internal object TokensDomainModule { fun provideRefreshMultiCurrencyWalletQuotesUseCase( currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, + multiQuoteFetcher: MultiQuoteFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): RefreshMultiCurrencyWalletQuotesUseCase { return RefreshMultiCurrencyWalletQuotesUseCase( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + multiQuoteFetcher = multiQuoteFetcher, + tokensFeatureToggles = tokensFeatureToggles, ) } @@ -386,20 +402,28 @@ internal object TokensDomainModule { tokensFeatureToggles: TokensFeatureToggles, currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, + quotesRepositoryV2: QuotesRepositoryV2, networksRepository: NetworksRepository, stakingRepository: StakingRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, + singleQuoteSupplier: SingleQuoteSupplier, + singleYieldBalanceSupplier: SingleYieldBalanceSupplier, ): BaseCurrenciesStatusesOperations { return CachedCurrenciesStatusesOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + quotesRepositoryV2 = quotesRepositoryV2, networksRepository = networksRepository, stakingRepository = stakingRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiNetworkStatusSupplier = multiNetworkStatusSupplier, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, + singleQuoteSupplier = singleQuoteSupplier, + singleYieldBalanceSupplier = singleYieldBalanceSupplier, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -410,21 +434,35 @@ internal object TokensDomainModule { tokensFeatureToggles: TokensFeatureToggles, currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, + quotesRepositoryV2: QuotesRepositoryV2, networksRepository: NetworksRepository, stakingRepository: StakingRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, + singleQuoteSupplier: SingleQuoteSupplier, + singleYieldBalanceSupplier: SingleYieldBalanceSupplier, ): BaseCurrencyStatusOperations { return CachedCurrenciesStatusesOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + quotesRepositoryV2 = quotesRepositoryV2, networksRepository = networksRepository, stakingRepository = stakingRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiNetworkStatusSupplier = multiNetworkStatusSupplier, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, + singleQuoteSupplier = singleQuoteSupplier, + singleYieldBalanceSupplier = singleYieldBalanceSupplier, tokensFeatureToggles = tokensFeatureToggles, ) } + + @Provides + @Singleton + fun provideGetCryptoCurrenciesUseCase(currenciesRepository: CurrenciesRepository): GetCryptoCurrenciesUseCase { + return GetCryptoCurrenciesUseCase(currenciesRepository) + } } \ No newline at end of file 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 adc63cbd3d..45734e3796 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 @@ -30,6 +30,21 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideGetEthSpecificFeeUseCase(walletManagersFacade: WalletManagersFacade): GetEthSpecificFeeUseCase { + return GetEthSpecificFeeUseCase(walletManagersFacade = walletManagersFacade) + } + + @Provides + @Singleton + fun provideTransferGetFeeUseCase(walletManagersFacade: WalletManagersFacade): GetTransferFeeUseCase { + return GetTransferFeeUseCase( + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig(), + ) + } + @Provides @Singleton fun provideSendTransactionUseCase( @@ -147,4 +162,30 @@ internal object TransactionDomainModule { fun provideGetAllowanceUseCase(transactionRepository: TransactionRepository): GetAllowanceUseCase { return GetAllowanceUseCase(transactionRepository) } + + @Provides + @Singleton + fun provideCreateTransferTransactionUseCase( + transactionRepository: TransactionRepository, + ): CreateTransferTransactionUseCase { + return CreateTransferTransactionUseCase(transactionRepository) + } + + @Provides + @Singleton + fun providePrepareForSendUseCase( + transactionRepository: TransactionRepository, + cardSdkConfigRepository: CardSdkConfigRepository, + ): PrepareForSendUseCase { + return PrepareForSendUseCase(transactionRepository, cardSdkConfigRepository) + } + + @Provides + @Singleton + fun provideSignUseCase( + walletManagersFacade: WalletManagersFacade, + cardSdkConfigRepository: CardSdkConfigRepository, + ): SignUseCase { + return SignUseCase(cardSdkConfigRepository, walletManagersFacade) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt index e542ddc5fd..fd5d2491d3 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt @@ -2,6 +2,8 @@ package com.tangem.tap.di.domain import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.walletconnect.repository.WalletConnectRepository +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.WcSessionsUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -19,4 +21,10 @@ internal object WalletConnectDomainModule { ): CheckIsWalletConnectAvailableUseCase { return CheckIsWalletConnectAvailableUseCase(walletConnectRepository = walletConnectRepository) } + + @Provides + @Singleton + fun providesWcSessionsUseCase(sessionsManager: WcSessionsManager): WcSessionsUseCase { + return WcSessionsUseCase(sessionsManager) + } } \ No newline at end of file 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 9be71f685e..4096521b75 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 @@ -14,6 +14,8 @@ import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSy import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.features.nft.NFTFeatureToggles +import com.tangem.operations.attestation.CardArtworksProvider +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -172,8 +174,16 @@ internal object WalletsDomainModule { @Provides @Singleton - fun provideGetCardImageUseCase(onlineCardVerifier: OnlineCardVerifier): GetCardImageUseCase { - return GetCardImageUseCase(verifier = onlineCardVerifier) + fun provideGetCardImageUseCase( + onlineCardVerifier: OnlineCardVerifier, + cardArtworksProvider: CardArtworksProvider, + cardSdkFeatureToggles: CardSdkFeatureToggles, + ): GetCardImageUseCase { + return GetCardImageUseCase( + verifier = onlineCardVerifier, + cardArtworksProvider = cardArtworksProvider, + cardSdkFeatureToggles = cardSdkFeatureToggles, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt b/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt index 7117819925..547dfbd6ce 100644 --- a/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt +++ b/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt @@ -6,6 +6,7 @@ import com.tangem.domain.common.TwinCardNumber import com.tangem.domain.common.getTwinCardNumber import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.wallets.models.Artwork +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.operations.attestation.api.models.CardVerifyAndGetInfo @@ -37,7 +38,7 @@ suspend fun CardDTO.getOrLoadCardArtworkUrl( if (artworkId.isNullOrEmpty()) { ifAnyError() } else { - OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) + CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) } } 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 890ce4ba9b..521c1444d8 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 @@ -4,14 +4,12 @@ import arrow.core.Either import arrow.core.getOrElse import arrow.core.raise.catch import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils import com.tangem.common.CompletionResult import com.tangem.common.card.EllipticCurve -import com.tangem.common.core.CardSession -import com.tangem.common.core.CardSessionRunnable -import com.tangem.common.core.CompletionCallback -import com.tangem.common.core.TangemError -import com.tangem.common.core.TangemSdkError +import com.tangem.common.core.* import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toDecompressedPublicKey import com.tangem.common.extensions.toHexString import com.tangem.common.map import com.tangem.common.timemeasure.RealtimeMonotonicTimeSource @@ -46,7 +44,7 @@ import timber.log.Timber import kotlin.coroutines.resume import kotlin.time.measureTimedValue -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") class VisaCardActivationTask @AssistedInject constructor( @Assisted private val mode: VisaCardActivationTaskMode, @Assisted private val activationInput: VisaActivationInput, @@ -97,7 +95,23 @@ class VisaCardActivationTask @AssistedInject constructor( context.signAuthorizationChallenge(mode.authorizationChallenge) } is VisaCardActivationTaskMode.SignOnly -> { - context.signData(mode.dataToSignByCardWallet) + val wallet = + card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } + ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + + val derivedPublicKey = when (val deriveKeyResult = context.deriveKey(wallet.publicKey)) { + is CompletionResult.Failure -> { + return CompletionResult.Failure(deriveKeyResult.error) + } + is CompletionResult.Success -> { + deriveKeyResult.data + } + } + + context.signData( + mode.dataToSignByCardWallet, + derivedPublicKey, + ) } } } @@ -138,9 +152,35 @@ class VisaCardActivationTask @AssistedInject constructor( private suspend fun SessionContext.processSignedAuthorizationChallenge( signedChallenge: VisaAuthSignedChallenge, ): CompletionResult { + when (val createWalletResult = createWallet()) { + is CompletionResult.Failure -> return CompletionResult.Failure(createWalletResult.error) + is CompletionResult.Success -> {} + } + + val card = + session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + + val wallet = + card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } + ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + + val derivedPublicKey = when (val deriveKeyResult = deriveKey(wallet.publicKey)) { + is CompletionResult.Failure -> { + return CompletionResult.Failure(deriveKeyResult.error) + } + is CompletionResult.Success -> { + deriveKeyResult.data + } + } + + val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnSecp256k1(derivedPublicKey.publicKey) + .getOrElse { return CompletionResult.Failure(it.tangemError) } + .value + return coroutineScope { - val dataToSignDeferred = async { getDataToSign(signedChallenge) } - val otpTaskDeferred = async { createWallet() } + val dataToSignDeferred = + async { getDataToSign(signedChallenge = signedChallenge, cardWalletAddress = walletAddress) } + val otpTaskDeferred = async { createOTP() } val dataToSign = dataToSignDeferred.await() .getOrElse { @@ -150,12 +190,16 @@ class VisaCardActivationTask @AssistedInject constructor( otpTaskDeferred.await() - signData(dataToSign) + signData( + dataToSign = dataToSign, + derivedPublicKey = derivedPublicKey, + ) } } private suspend fun SessionContext.getDataToSign( signedChallenge: VisaAuthSignedChallenge, + cardWalletAddress: String, ): Either = either { catch( block = { @@ -168,7 +212,12 @@ class VisaCardActivationTask @AssistedInject constructor( raise(VisaActivationError.WrongRemoteState.tangemError) } - visaActivationRepository.getCardWalletAcceptanceData(remoteState.request) + visaActivationRepository.getCardWalletAcceptanceData( + VisaCardWalletDataToSignRequest( + activationOrderInfo = remoteState.activationOrderInfo, + cardWalletAddress = cardWalletAddress, + ), + ) }, catch = { raise(VisaAuthorizationAPIError.tangemError) @@ -182,7 +231,7 @@ class VisaCardActivationTask @AssistedInject constructor( val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) return if (card.wallets.any { it.curve == EllipticCurve.Secp256k1 }) { - createOTP() + CompletionResult.Success(Unit) } else { val createWalletTask = CreateWalletTask(EllipticCurve.Secp256k1) @@ -199,7 +248,7 @@ class VisaCardActivationTask @AssistedInject constructor( when (val result = timedResult.value) { is CompletionResult.Success -> { Timber.i("CreateWalletTask success") - createOTP() + CompletionResult.Success(Unit) } is CompletionResult.Failure -> { Timber.e("CreateWalletTask failure ${result.error}") @@ -245,26 +294,15 @@ class VisaCardActivationTask @AssistedInject constructor( private suspend fun SessionContext.signData( dataToSign: VisaDataToSignByCardWallet, + derivedPublicKey: ExtendedPublicKey, ): CompletionResult { val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) - val derivedPublicKey = when (val deriveKeyResult = deriveKey(wallet.publicKey)) { - is CompletionResult.Failure -> { - return CompletionResult.Failure(deriveKeyResult.error) - } - is CompletionResult.Success -> { - deriveKeyResult.data - } - } - - val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnSecp256k1(derivedPublicKey.publicKey) - .getOrElse { return CompletionResult.Failure(it.tangemError) } - .value - val task = SignHashCommand( hash = dataToSign.hashToSign.hexToBytes(), walletPublicKey = wallet.publicKey, @@ -286,8 +324,8 @@ class VisaCardActivationTask @AssistedInject constructor( Timber.i("SignHashCommand success") handleSignedData( dataToSign = dataToSign, - walletAddress = walletAddress, response = result.data, + derivedPublicKey = derivedPublicKey, ) } is CompletionResult.Failure -> { @@ -313,7 +351,7 @@ class VisaCardActivationTask @AssistedInject constructor( private suspend fun SessionContext.handleSignedData( dataToSign: VisaDataToSignByCardWallet, - walletAddress: String, + derivedPublicKey: ExtendedPublicKey, response: SignHashResponse, ): CompletionResult { val otp = otpStorage.getOTP(cardId) ?: run { @@ -321,11 +359,16 @@ class VisaCardActivationTask @AssistedInject constructor( otpStorage.getOTP(cardId) ?: return CompletionResult.Failure(VisaActivationError.MissingRootOTP.tangemError) } + val rsvSignature = EthereumUtils.prepareSignedMessageData( + signedHash = response.signature, + hashToSign = dataToSign.hashToSign.hexToBytes(), + publicKey = derivedPublicKey.publicKey.toDecompressedPublicKey(), + ) + val signedActivationData = dataToSign.sign( - cardWalletAddress = walletAddress, rootOTP = otp.rootOTP.toHexString(), otpCounter = otp.counter, - signature = response.signature.toHexString(), + signature = rsvSignature, ) return setupAccessCode().map { diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt index 18ec47b7fb..2fb700cbd7 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt @@ -1,6 +1,7 @@ package com.tangem.tap.domain.tasks.visa import arrow.core.getOrElse +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils import com.tangem.common.CompletionResult import com.tangem.common.card.Card import com.tangem.common.card.CardWallet @@ -10,7 +11,7 @@ import com.tangem.common.core.CardSessionRunnable import com.tangem.common.core.CompletionCallback import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.hexToBytes -import com.tangem.common.extensions.toHexString +import com.tangem.common.extensions.toDecompressedPublicKey import com.tangem.core.error.ext.tangemError import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey @@ -129,6 +130,7 @@ class VisaCustomerWalletApproveTask( targetWalletPublicKey = wallet.publicKey, derivationPath = derivationPath, session = session, + extendedPublicKey = extendedPublicKey, callback = callback, ) } @@ -149,6 +151,7 @@ class VisaCustomerWalletApproveTask( signApproveData( targetWalletPublicKey = publicKey, derivationPath = null, + extendedPublicKey = null, session = session, callback = callback, ) @@ -157,11 +160,14 @@ class VisaCustomerWalletApproveTask( private fun signApproveData( targetWalletPublicKey: ByteArray, derivationPath: DerivationPath?, + extendedPublicKey: ExtendedPublicKey?, session: CardSession, callback: CompletionCallback, ) { + val hashToSign = visaDataForApprove.dataToSign.hashToSign.hexToBytes() + val signTask = SignHashCommand( - hash = visaDataForApprove.dataToSign.hashToSign.hexToBytes(), + hash = hashToSign, walletPublicKey = targetWalletPublicKey, derivationPath = derivationPath, ) @@ -169,11 +175,18 @@ class VisaCustomerWalletApproveTask( signTask.run(session) { result -> when (result) { is CompletionResult.Success -> { + val rsvSignature = EthereumUtils.prepareSignedMessageData( + signedHash = result.data.signature, + hashToSign = hashToSign, + publicKey = extendedPublicKey?.publicKey?.toDecompressedPublicKey() + ?: targetWalletPublicKey.toDecompressedPublicKey(), + ) + scanCard( session = session, callback = callback, signedData = visaDataForApprove.dataToSign.sign( - signature = result.data.signature.toHexString(), + signature = rsvSignature, customerWalletAddress = visaDataForApprove.targetAddress, ), ) 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 2927924f8c..adc8e80741 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 @@ -9,4 +9,10 @@ internal class DefaultTokensFeatureToggles( override val isNetworksLoadingRefactoringEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(name = "NETWORKS_LOADING_REFACTORING_ENABLED") + + override val isQuotesLoadingRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "QUOTES_LOADING_REFACTORING_ENABLED") + + override val isStakingLoadingRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "STAKING_LOADING_REFACTORING_ENABLED") } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt index e7a9f1873f..95e75741b7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt @@ -21,8 +21,6 @@ internal data class UserWalletPublicInformation( val name: String, @Json(name = "walletId") val walletId: UserWalletId, - @Json(name = "artworkUrl") - val artworkUrl: String, @Json(name = "cardsInWallet") val cardsInWallet: Set, @Json(name = "scanResponse") diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt index 9ee2498fa8..d963dc5794 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt @@ -20,9 +20,12 @@ internal class DefaultUserWalletsPublicInformationRepository( moshi: Moshi, private val secureStorage: SecureStorage, ) : UserWalletsPublicInformationRepository { - private val publicInformationAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java), - ) + + private val publicInformationAdapter: JsonAdapter> by lazy { + moshi.adapter( + Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java), + ) + } override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult { return withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt index 13c56f4f22..a1888ea494 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt @@ -24,12 +24,15 @@ internal class DefaultUserWalletsSensitiveInformationRepository( private val secureStorage: SecureStorage, ) : UserWalletsSensitiveInformationRepository { - private val sensitiveInformationAdapter: JsonAdapter = moshi.adapter( - UserWalletSensitiveInformation::class.java, - ) - private val encryptedSensitiveInformationMapAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java), - ) + private val sensitiveInformationAdapter: JsonAdapter by lazy { + moshi.adapter(UserWalletSensitiveInformation::class.java) + } + + private val encryptedSensitiveInformationMapAdapter: JsonAdapter> by lazy { + moshi.adapter( + Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java), + ) + } override suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult { if (encryptionKey == null) { diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt index 4c73101416..8dca07bec0 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt @@ -15,7 +15,6 @@ internal val UserWallet.publicInformation: UserWalletPublicInformation get() = UserWalletPublicInformation( name = name, walletId = walletId, - artworkUrl = artworkUrl, cardsInWallet = cardsInWallet, isMultiCurrency = isMultiCurrency, scanResponse = scanResponse.copy( @@ -31,7 +30,6 @@ internal fun UserWalletPublicInformation.toUserWallet(): UserWallet { return UserWallet( name = name, walletId = walletId, - artworkUrl = artworkUrl, cardsInWallet = cardsInWallet, scanResponse = scanResponse, isMultiCurrency = isMultiCurrency, 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 ad20167f85..b83d23f8d9 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 @@ -128,6 +128,8 @@ internal class VisaCardScanHandler @Inject constructor( Timber.i("Requesting challenge for wallet authorization") val challengeResponse = runCatching { + // TODO [REDACTED_TASK_KEY] + error("sign and get specific error to switch to card_id flow") visaAuthRepository.getCardWalletAuthChallenge(cardWalletAddress = walletAddress.value) }.getOrElse { Timber.i( @@ -180,6 +182,7 @@ internal class VisaCardScanHandler @Inject constructor( return CompletionResult.Success(VisaCardActivationStatus.Activated(authorizationTokensResponse)) } + @Suppress("LongMethod") private suspend fun SessionContext.handleCardAuthorization( cardWalletAddress: String, ): CompletionResult { @@ -232,11 +235,17 @@ internal class VisaCardScanHandler @Inject constructor( tokens = authorizationTokensResponse, ) - val activationRemoteState = visaActivationRepository.getActivationRemoteState() + val activationRemoteState = runCatching { + visaActivationRepository.getActivationRemoteState() + }.getOrElse { + Timber.e("Failed to sign challenge with Card public key. Plain error: ${it.message}") + return CompletionResult.Failure(VisaAuthorizationAPIError.tangemError) + } val error = when (activationRemoteState) { VisaActivationRemoteState.BlockedForActivation -> VisaActivationError.BlockedForActivation VisaActivationRemoteState.Activated -> VisaActivationError.InvalidActivationState + VisaActivationRemoteState.Failed -> VisaActivationError.FailedRemoteState else -> null } 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 aa615456cc..ea5b87285c 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 @@ -4,8 +4,8 @@ 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.blockchains.ethereum.EthereumUtils.toKeccak 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 @@ -16,6 +16,7 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic.TransactionSent.MemoType +import com.tangem.data.walletconnect.network.ethereum.LegacySdkHelper import com.tangem.operations.sign.SignHashCommand import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.safeUpdate @@ -101,7 +102,7 @@ class WalletConnectSdkHelper { sourceAddress = transaction.from, destinationAddress = destinationAddress, extras = EthereumTransactionExtras( - data = transaction.data.removePrefix(HEX_PREFIX).hexToBytes(), + callData = CompiledSmartContractCallData(transaction.data.removePrefix(HEX_PREFIX).hexToBytes()), gasLimit = gasLimit.toBigInteger(), nonce = transaction.nonce?.hexToBigDecimal()?.toBigInteger(), ), @@ -191,7 +192,7 @@ class WalletConnectSdkHelper { val gasLimitResult = (walletManager as? EthereumGasLoader)?.getGasLimit( amount = Amount(value, walletManager.wallet.blockchain), destination = transaction.to ?: "", - data = transaction.data, + callData = CompiledSmartContractCallData(transaction.data.hexToBytes()), ) return when (gasLimitResult) { is Result.Success -> gasLimitResult.data.toBigDecimal().multiply(BigDecimal("1.2")) @@ -357,36 +358,9 @@ class WalletConnectSdkHelper { ) } - private fun createMessageData(message: WcSignMessage): ByteArray { - val messageData = try { - message.data.removePrefix(HEX_PREFIX).hexToBytes() - } catch (exception: Exception) { - message.data.asciiToHex()?.hexToBytes() ?: byteArrayOf() - } + private fun createMessageData(message: WcSignMessage): ByteArray = LegacySdkHelper.createMessageData(message.data) - val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray() - return (prefixData + messageData).toKeccak() - } - - private fun String.hexToAscii(): String? { - return try { - removePrefix(HEX_PREFIX).hexToBytes() - .map { - val char = it.toInt().toChar() - if (char.isAscii()) char else return null - } - .joinToString("") - } catch (exception: Exception) { - return null - } - } - - private fun String.asciiToHex(): String? { - return map { - if (!it.isAscii()) return null - Integer.toHexString(it.code) - }.joinToString("") - } + private fun String.hexToAscii(): String? = LegacySdkHelper.hexToAscii(hex = this) suspend fun signPersonalMessage( hashToSign: ByteArray, @@ -535,7 +509,6 @@ class WalletConnectSdkHelper { "{\"signature\":\"$signature\",\"publicKey\":\"$publicKey\"}" private companion object { - const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n" const val HEX_PREFIX = "0x" const val DEFAULT_MAX_GASLIMIT = 350000 // TODO remove after [REDACTED_JIRA] diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt index 3fefa3a904..fe64265670 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt @@ -31,7 +31,7 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.onboarding.data.model.CreateWalletResponse import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource import com.tangem.feature.wallet.presentation.wallet.domain.BackupValidator -import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.CreateProductWalletTaskResponse import com.tangem.sdk.extensions.localizedDescriptionRes @@ -250,7 +250,7 @@ private suspend fun loadArtworkForCard(cardId: String, cardPublicKey: ByteArray, if (artworkId.isNullOrEmpty()) { defaultArtwork ?: Uri.EMPTY } else { - Uri.parse(OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)) + Uri.parse(CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)) } } is Result.Failure -> defaultArtwork ?: Uri.EMPTY diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt index 5087ef7019..15e74c5498 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt @@ -1,68 +1,14 @@ package com.tangem.tap.network.auth -import com.tangem.common.CardIdRangeDec -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.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.wallets.models.UserWallet import com.tangem.lib.auth.ExpressAuthProvider -import kotlinx.coroutines.runBlocking import java.util.UUID import java.util.concurrent.atomic.AtomicReference -internal class DefaultExpressAuthProvider( - private val userWalletsStore: UserWalletsStore, - private val appPreferencesStore: AppPreferencesStore, -) : ExpressAuthProvider { +internal class DefaultExpressAuthProvider : ExpressAuthProvider { private var uuid = AtomicReference(UUID.randomUUID()) - override fun getUserId(): String { - return userWalletsStore.selectedUserWalletOrNull?.walletId?.stringValue ?: error("No user id provided") - } - override fun getSessionId(): String { return uuid.get().toString() } - - override fun getRefCode(): String { - val selectedUserWallet = userWalletsStore.selectedUserWalletOrNull ?: error("Can not get selected user wallet") - - return when { - isRing(selectedUserWallet) -> "ring" - isChangeNow(selectedUserWallet) -> "ChangeNow" - isPartner(selectedUserWallet) -> "partner" - else -> "" - } - } - - private fun isRing(selectedUserWallet: UserWallet): Boolean { - val addedWalletsWithRings = runBlocking { - appPreferencesStore.getSyncOrDefault( - key = PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY, - default = emptySet(), - ) - } - - return addedWalletsWithRings.contains(selectedUserWallet.walletId.stringValue) - } - - private fun isChangeNow(selectedUserWallet: UserWallet): Boolean { - val changeNowRange = CardIdRangeDec( - start = "AF99001800554008", - end = "AF99001800559994", - ) - val card = selectedUserWallet.scanResponse.card - return card.batchId == BATCH_ID_CHANGENOW || changeNowRange?.contains(card.cardId) == true - } - - private fun isPartner(selectedUserWallet: UserWallet): Boolean { - return selectedUserWallet.scanResponse.card.batchId == BATCH_ID_PARTNER - } - - private companion object { - const val BATCH_ID_CHANGENOW = "BB000013" - const val BATCH_ID_PARTNER = "AF990015" - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index c72a9c145c..5f724624af 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -2,8 +2,6 @@ package com.tangem.tap.network.auth.di import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider @@ -30,14 +28,8 @@ internal class AuthModule { @Provides @Singleton - fun provideExpressAuthProvider( - userWalletsStore: UserWalletsStore, - appPreferencesStore: AppPreferencesStore, - ): ExpressAuthProvider { - return DefaultExpressAuthProvider( - userWalletsStore = userWalletsStore, - appPreferencesStore = appPreferencesStore, - ) + fun provideExpressAuthProvider(): ExpressAuthProvider { + return DefaultExpressAuthProvider() } @Provides diff --git a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt deleted file mode 100644 index 621ce6f4e9..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ /dev/null @@ -1,366 +0,0 @@ -package com.tangem.tap.proxy - -import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager -import com.tangem.blockchain.blockchains.optimism.EthereumOptimisticRollupWalletManager -import com.tangem.blockchain.common.* -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.blockchain.extensions.Result -import com.tangem.blockchain.externallinkprovider.TxExploreState -import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.lib.crypto.TransactionManager -import com.tangem.lib.crypto.models.* -import java.math.BigDecimal -import java.math.BigInteger -import java.math.MathContext -import java.math.RoundingMode - -@Suppress("LargeClass") -class TransactionManagerImpl( - private val walletManagersFacade: WalletManagersFacade, - private val userWalletsListManager: UserWalletsListManager, -) : TransactionManager { - - override fun getExplorerTransactionLink(networkId: String, txAddress: String): String { - val blockchain = Blockchain.fromNetworkId(networkId) ?: error("blockchain not found") - return when (val txUrlState = blockchain.getExploreTxUrl(txAddress)) { - TxExploreState.Unsupported -> "" - is TxExploreState.Url -> txUrlState.url - } - } - - override suspend fun updateWalletManager(networkId: String, derivationPath: String?) { - val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - getActualWalletManager(blockchain, derivationPath).update() - } - - @Throws(IllegalStateException::class) - override suspend fun getFee( - networkId: String, - amountToSend: Amount, - currencyToSend: Currency, - destinationAddress: String, - increaseBy: Int?, - data: String?, - derivationPath: String?, - ): ProxyFees { - val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - val walletManager = getActualWalletManager(blockchain, derivationPath) - if (walletManager is EthereumWalletManager) { - if (walletManager is EthereumOptimisticRollupWalletManager) { - return getFeeForOptimismBlockchain( - walletManager = walletManager, - amount = amountToSend, - destinationAddress = destinationAddress, - data = data, - ) - } - return getFeeForEthereumBlockchain( - walletManager = walletManager, - blockchain = blockchain, - amountToSend = amountToSend, - destinationAddress = destinationAddress, - data = data, - increaseBy = increaseBy, - ) - } else { - return getFeeForBlockchain( - walletManager = walletManager, - amountToSend = amountToSend, - destinationAddress = destinationAddress, - ) - } - } - - override fun getBlockchainInfo(networkId: String): ProxyNetworkInfo { - val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - return ProxyNetworkInfo( - name = blockchain.fullName, - blockchainId = blockchain.id, - ) - } - - private suspend fun getFeeForBlockchain( - walletManager: WalletManager, - amountToSend: Amount, - destinationAddress: String, - ): ProxyFees { - val fee = (walletManager as? TransactionSender)?.getFee( - amount = amountToSend, - destination = destinationAddress, - ) ?: error("Cannot cast to TransactionSender") - return when (fee) { - is Result.Success -> { - // for not EVM blockchains set gasLimit ZERO for now - when (fee.data) { - is TransactionFee.Single -> { - val singleFee = when (val normalFee = (fee.data as TransactionFee.Single).normal) { - is Fee.CardanoToken -> { - ProxyFee.CardanoToken( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - minAdaValue = normalFee.minAdaValue, - ) - } - is Fee.Filecoin -> { - ProxyFee.Filecoin( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - gasPremium = normalFee.gasPremium, - ) - } - is Fee.Sui -> { - ProxyFee.Sui( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - gasPrice = normalFee.gasPrice, - gasBudget = normalFee.gasBudget, - ) - } - else -> { - ProxyFee.Common( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - ) - } - } - - ProxyFees.SingleFee(singleFee = singleFee) - } - is TransactionFee.Choosable -> { - val choosableFee = fee.data as TransactionFee.Choosable - ProxyFees.MultipleFees( - minFee = ProxyFee.Common( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = choosableFee.minimum.amount), - ), - normalFee = ProxyFee.Common( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = choosableFee.normal.amount), - ), - priorityFee = ProxyFee.Common( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = choosableFee.priority.amount), - ), - ) - } - } - } - is Result.Failure -> { - error(fee.error.message ?: fee.error.customMessage) - } - } - } - - @Suppress("LongParameterList") - private suspend fun getFeeForEthereumBlockchain( - walletManager: EthereumWalletManager, - blockchain: Blockchain, - amountToSend: Amount, - destinationAddress: String, - data: String?, - increaseBy: Int?, - ): ProxyFees { - val gasLimit = getGasLimit( - evmWalletManager = walletManager, - amount = amountToSend, - destinationAddress = destinationAddress, - data = data, - ).increaseBigIntegerByPercents(increaseBy) - return when (val gasPrice = walletManager.getGasPrice()) { - is Result.Success -> { - createMultipleProxyFees(gasPrice = gasPrice.data, gasLimit = gasLimit, blockchain = blockchain) - } - is Result.Failure -> { - error(gasPrice.error.message ?: gasPrice.error.customMessage) - } - } - } - - private suspend fun getFeeForOptimismBlockchain( - walletManager: EthereumOptimisticRollupWalletManager, - amount: Amount, - destinationAddress: String, - data: String?, - ): ProxyFees { - val fee = if (data.isNullOrEmpty()) { - walletManager.getFee(amount, destinationAddress) - } else { - walletManager.getFee(amount, destinationAddress, data) - } - return when (fee) { - is Result.Success -> { - val choosableFee = fee.data - - val minProxyFee = ProxyFee.Common( - gasLimit = (choosableFee.minimum as Fee.Ethereum).gasLimit, - fee = convertToProxyAmount(amount = choosableFee.minimum.amount), - ) - val normalProxyFee = ProxyFee.Common( - gasLimit = (choosableFee.normal as Fee.Ethereum).gasLimit, - fee = convertToProxyAmount(amount = choosableFee.normal.amount), - ) - val priorityProxyFee = ProxyFee.Common( - gasLimit = (choosableFee.priority as Fee.Ethereum).gasLimit, - fee = convertToProxyAmount(amount = choosableFee.priority.amount), - ) - - ProxyFees.MultipleFees( - minFee = minProxyFee, - normalFee = normalProxyFee, - priorityFee = priorityProxyFee, - ) - } - is Result.Failure -> { - error(fee.error.message ?: fee.error.customMessage) - } - } - } - - private suspend fun getGasLimit( - evmWalletManager: EthereumWalletManager, - amount: Amount, - destinationAddress: String, - data: String?, - ): BigInteger { - val result = if (data.isNullOrEmpty()) { - evmWalletManager.getGasLimit( - amount = amount, - destination = destinationAddress, - ) - } else { - evmWalletManager.getGasLimit( - amount = amount, - destination = destinationAddress, - data = data, - ) - } - when (result) { - is Result.Success -> { - return result.data - } - is Result.Failure -> { - error(result.error.message ?: result.error.customMessage) - } - } - } - - override suspend fun getFeeForGas(networkId: String, gas: BigInteger, derivationPath: String?): ProxyFees { - val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - val walletManager = getActualWalletManager(blockchain, derivationPath) - val gasPriceResult = (walletManager as? EthereumWalletManager)?.getGasPrice() - ?: error("not supported for $blockchain") - val gasPrice = when (gasPriceResult) { - is Result.Failure -> error("fail to receive gasPrice") - is Result.Success -> gasPriceResult.data - } - return createMultipleProxyFees(gasPrice, gas, blockchain) - } - - private suspend fun getActualWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager { - val selectedUserWallet = requireNotNull( - userWalletsListManager.selectedUserWalletSync, - ) { "userWallet or userWalletsListManager is null" } - val walletManager = walletManagersFacade.getOrCreateWalletManager( - selectedUserWallet.walletId, - blockchain, - derivationPath, - ) - - return requireNotNull(walletManager) { "no wallet manager found" } - } - - /** - * Create proxy fees - * - * @param gasPrice min fee gasPrice - * @param gasLimit - * @param blockchain - */ - private fun createMultipleProxyFees(gasPrice: BigInteger, gasLimit: BigInteger, blockchain: Blockchain): ProxyFees { - val patchedGasLimit = gasLimit.toBigDecimal().increaseForMantleIfNeeded(blockchain).toBigInteger() - val gasPriceNormal = gasPrice - .increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_NORMAL_FEE) - val gasPricePriority = gasPrice.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_PRIORITY_FEE) - val feeMin = patchedGasLimit.multiply(gasPrice).toBigDecimal( - scale = blockchain.decimals(), - mathContext = MathContext(blockchain.decimals(), RoundingMode.HALF_EVEN), - ).increaseForMantleIfNeeded(blockchain) - val feeNormal = patchedGasLimit.multiply(gasPriceNormal).toBigDecimal( - scale = blockchain.decimals(), - mathContext = MathContext(blockchain.decimals(), RoundingMode.HALF_EVEN), - ).increaseForMantleIfNeeded(blockchain) - val feePriority = patchedGasLimit.multiply(gasPricePriority).toBigDecimal( - scale = blockchain.decimals(), - mathContext = MathContext(blockchain.decimals(), RoundingMode.HALF_EVEN), - ).increaseForMantleIfNeeded(blockchain) - val minFee = ProxyFee.Common( - gasLimit = patchedGasLimit, - fee = ProxyAmount( - currencySymbol = blockchain.currency, - value = feeMin, - decimals = blockchain.decimals(), - ), - ) - val normalFee = ProxyFee.Common( - gasLimit = patchedGasLimit, - fee = ProxyAmount( - currencySymbol = blockchain.currency, - value = feeNormal, - decimals = blockchain.decimals(), - ), - ) - val priorityFee = ProxyFee.Common( - gasLimit = patchedGasLimit, - fee = ProxyAmount( - currencySymbol = blockchain.currency, - value = feePriority, - decimals = blockchain.decimals(), - ), - ) - return ProxyFees.MultipleFees( - minFee = minFee, - normalFee = normalFee, - priorityFee = priorityFee, - ) - } - - private fun convertToProxyAmount(amount: Amount): ProxyAmount { - return ProxyAmount( - currencySymbol = amount.currencySymbol, - value = amount.value ?: BigDecimal.ZERO, - decimals = amount.decimals, - ) - } - - /** - * Increase big integer by percents - * - * @param percents in format 150 -> 50% - * @return increased value - */ - private fun BigInteger.increaseBigIntegerByPercents(percents: Int?): BigInteger { - return if (percents != null && percents != 0) { - this.multiply(percents.toBigInteger()).divide(BigInteger("100")) - } else { - this - } - } - - // 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 - } - } - - companion object { - private const val MULTIPLIER_GAS_PRICE_FOR_NORMAL_FEE = 150 // 50% - private const val MULTIPLIER_GAS_PRICE_FOR_PRIORITY_FEE = 200 // 50% - 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/proxy/di/ProxyModule.kt b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt index e9bcad2a6b..4d59c1f56e 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 @@ -2,10 +2,8 @@ package com.tangem.tap.proxy.di import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager import com.tangem.tap.proxy.AppStateHolder -import com.tangem.tap.proxy.TransactionManagerImpl import com.tangem.tap.proxy.UserWalletManagerImpl import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -37,16 +35,4 @@ internal object ProxyModule { dispatchers = dispatchers, ) } - - @Provides - @Singleton - fun provideTransactionManager( - walletManagersFacade: WalletManagersFacade, - userWalletsListManager: UserWalletsListManager, - ): TransactionManager { - return TransactionManagerImpl( - walletManagersFacade = walletManagersFacade, - userWalletsListManager = userWalletsListManager, - ) - } } \ No newline at end of file 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 2525bdd3cd..9d8ccb0fbb 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 @@ -32,6 +32,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onramp.OnrampFeatureToggles +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository @@ -76,5 +77,6 @@ data class DaggerGraphState( val settingsManager: SettingsManager? = null, val uiMessageSender: UiMessageSender? = null, val onlineCardVerifier: OnlineCardVerifier? = null, + val cardArworksProvider: CardArtworksProvider? = null, val userWalletBuilderFactory: UserWalletBuilder.Factory? = null, ) : StateType \ No newline at end of file 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 3c0ac636bb..189f0f09e5 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 @@ -12,13 +12,12 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.markets.details.MarketsTokenDetailsComponent -import com.tangem.features.nft.component.NFTCollectionsComponent -import com.tangem.features.nft.component.NFTDetailsComponent -import com.tangem.features.nft.component.NFTReceiveComponent +import com.tangem.features.nft.component.* import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent import com.tangem.features.onramp.component.* import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.v2.api.NFTSendComponent import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent @@ -83,9 +82,8 @@ internal class ChildFactory @Inject constructor( private val sendComponentFactoryV2: com.tangem.features.send.v2.api.SendComponent.Factory, private val sendFeatureToggles: SendFeatureToggles, private val redesignedWalletConnectComponentFactory: RedisegnedWalletConnectComponent.Factory, - private val nftCollectionsComponentFactory: NFTCollectionsComponent.Factory, - private val nftReceiveComponentFactory: NFTReceiveComponent.Factory, - private val nftDetailsComponentFactory: NFTDetailsComponent.Factory, + private val nftComponentFactory: NFTComponent.Factory, + private val nftSendComponentFactory: NFTSendComponent.Factory, private val testerRouter: TesterRouter, private val routingFeatureToggles: RoutingFeatureToggles, private val walletConnectFeatureToggles: WalletConnectFeatureToggles, @@ -182,7 +180,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.OnrampSuccess -> { createComponentChild( context = context, - params = OnrampSuccessComponent.Params(route.externalTxId), + params = OnrampSuccessComponent.Params(route.txId), componentFactory = onrampSuccessComponentFactory, ) } @@ -405,24 +403,26 @@ internal class ChildFactory @Inject constructor( componentFactory = walletComponentFactory, ) } - is AppRoute.NFTCollections -> + is AppRoute.NFT -> createComponentChild( context = context, - params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftCollectionsComponentFactory, + params = NFTComponent.Params( + userWalletId = route.userWalletId, + walletName = route.walletName, + ), + componentFactory = nftComponentFactory, ) - is AppRoute.NFTReceive -> + is AppRoute.NFTSend -> { createComponentChild( context = context, - params = NFTReceiveComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftReceiveComponentFactory, - ) - is AppRoute.NFTDetails -> - createComponentChild( - context = context, - params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset), - componentFactory = nftDetailsComponentFactory, + params = NFTSendComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.nftCollectionName, + ), + componentFactory = nftSendComponentFactory, ) + } is AppRoute.OnboardingNote, is AppRoute.SaveWallet, is AppRoute.OnboardingOther, @@ -703,7 +703,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.OnrampSuccess -> { route.asComponentChild( contextProvider = contextProvider(route, contextFactory), - params = OnrampSuccessComponent.Params(route.externalTxId), + params = OnrampSuccessComponent.Params(route.txId), componentFactory = onrampSuccessComponentFactory, ) } @@ -757,24 +757,26 @@ internal class ChildFactory @Inject constructor( componentFactory = storiesComponentFactory, ) } - is AppRoute.NFTCollections -> + is AppRoute.NFT -> route.asComponentChild( contextProvider = contextProvider(route, contextFactory), - params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftCollectionsComponentFactory, + params = NFTComponent.Params( + userWalletId = route.userWalletId, + walletName = route.walletName, + ), + componentFactory = nftComponentFactory, ) - is AppRoute.NFTReceive -> + is AppRoute.NFTSend -> { route.asComponentChild( contextProvider = contextProvider(route, contextFactory), - params = NFTReceiveComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftReceiveComponentFactory, - ) - is AppRoute.NFTDetails -> - route.asComponentChild( - contextProvider = contextProvider(route, contextFactory), - params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset), - componentFactory = nftDetailsComponentFactory, + params = NFTSendComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.nftCollectionName, + ), + componentFactory = nftSendComponentFactory, ) + } } // endregion } diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt index c66581f06d..7b9bd90813 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt @@ -39,7 +39,6 @@ internal class DefaultDerivationsRepositoryTest { private val defaultUserWallet = UserWallet( name = "", walletId = defaultUserWalletId, - artworkUrl = "", cardsInWallet = setOf(), isMultiCurrency = false, scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()), diff --git a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt index 8180ca85ab..209dcbb443 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt @@ -194,7 +194,6 @@ internal class BiometricUserWalletsListManagerTest(private val model: Model) { return UserWallet( name = "Wallet $id", walletId = UserWalletId(stringValue = id), - artworkUrl = "", cardsInWallet = emptySet(), isMultiCurrency = true, hasBackupError = false, diff --git a/build.gradle.kts b/build.gradle.kts index 1b240b2cfd..4cbeef6686 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,9 +59,41 @@ val assembleInternalQA by tasks.registering { } } +val assembleExternalQA by tasks.registering { + group = "build" + description = "Builds external APK to 'build/outputs' directory" + + val appOutputApkDir = "$projectDir/app/build/outputs/apk/external" + val rootOutputApkDir = "$buildDir/outputs" + val injected = objects.newInstance() + + dependsOn(":app:assembleExternal") + + doFirst { + injected.fs.delete { + delete(appOutputApkDir) + delete("$rootOutputApkDir/app-external.apk") + } + } + doLast { + injected.fs.copy { + from("$appOutputApkDir/app-external.apk") + into(rootOutputApkDir) + } + } +} + +val assembleQA by tasks.registering { + group = "build" + description = "Builds internal and external APKs to 'build/outputs' directory" + + dependsOn(assembleInternalQA) + dependsOn(assembleExternalQA) +} + val generateComposeMetrics by tasks.registering { group = "other" - description = "Build internal APK and generates compose metrics to 'build/compose-metrics' directory" + description = "Build external APK and generates compose metrics to 'build/compose-metrics' directory" subprojects { tasks.withType { @@ -86,5 +118,5 @@ val generateComposeMetrics by tasks.registering { } } - finalizedBy(assembleInternalQA) + finalizedBy(assembleExternalQA) } \ 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 cc4bda0a08..ca72fdcd9a 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 @@ -233,8 +233,8 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class OnrampSuccess( - val externalTxId: String, - ) : AppRoute(path = "/onramp/success/$externalTxId"), RouteBundleParams { + val txId: String, + ) : AppRoute(path = "/onramp/success/$txId"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) } @@ -281,18 +281,15 @@ sealed class AppRoute(val path: String) : Route { ) : AppRoute(path = "/stories$storyId") @Serializable - data class NFTCollections( + data class NFT( val userWalletId: UserWalletId, - ) : AppRoute(path = "/nft_collections/${userWalletId.stringValue}") + val walletName: String, + ) : AppRoute(path = "/nft/${userWalletId.stringValue}") @Serializable - data class NFTReceive( - val userWalletId: UserWalletId, - ) : AppRoute(path = "/nft_receive/${userWalletId.stringValue}") - - @Serializable - data class NFTDetails( + data class NFTSend( val userWalletId: UserWalletId, val nftAsset: NFTAsset, - ) : AppRoute(path = "/nft_details/${userWalletId.stringValue}/${nftAsset.collectionId}/${nftAsset.id.stringValue}") + val nftCollectionName: String, + ) : AppRoute(path = "/send/nft/${userWalletId.stringValue}/$nftCollectionName/${nftAsset.id}") } \ No newline at end of file diff --git a/common/test/build.gradle.kts b/common/test/build.gradle.kts index 00c1510373..62d076fdff 100644 --- a/common/test/build.gradle.kts +++ b/common/test/build.gradle.kts @@ -9,10 +9,15 @@ android { } dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + implementation(projects.data.common) + implementation(projects.data.staking) implementation(projects.domain.legacy) implementation(projects.domain.models) + implementation(projects.domain.staking.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) @@ -20,6 +25,7 @@ dependencies { implementation(projects.libs.blockchainSdk) implementation(deps.androidx.datastore) + implementation(deps.jodatime) implementation(deps.test.coroutine) implementation(tangemDeps.blockchain) diff --git a/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt new file mode 100644 index 0000000000..2e3f56492e --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt @@ -0,0 +1,19 @@ +package com.tangem.common.test.data.quote + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockQuoteResponseFactory { + + fun createSinglePrice(value: BigDecimal): QuotesResponse.Quote { + return QuotesResponse.Quote( + price = value, + priceChange24h = value, + priceChange1w = value, + priceChange30d = value, + ) + } +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt new file mode 100644 index 0000000000..ac570f6d81 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt @@ -0,0 +1,14 @@ +package com.tangem.common.test.data.quote + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.local.quote.converter.QuoteConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Quote + +fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): Quote { + return QuoteConverter(source = source).convert(value = mapOf(rawCurrencyId to this).entries.first()) +} + +fun Pair.toDomain(source: StatusSource = StatusSource.ACTUAL): Quote { + return QuoteConverter(source = source).convert(value = mapOf(this).entries.first()) +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldBalanceWrapperDTOFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldBalanceWrapperDTOFactory.kt new file mode 100644 index 0000000000..1616a4c847 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldBalanceWrapperDTOFactory.kt @@ -0,0 +1,59 @@ +package com.tangem.common.test.data.staking + +import com.tangem.datasource.api.stakekit.models.request.Address +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO +import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.domain.staking.model.StakingID +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockYieldBalanceWrapperDTOFactory { + + val defaultStakingId = StakingID( + integrationId = "ton-ton-chorus-one-pools-staking", + address = "0x1", + ) + + fun createWithBalance(stakingId: StakingID = defaultStakingId): YieldBalanceWrapperDTO { + return YieldBalanceWrapperDTO( + addresses = Address(address = stakingId.address), + balances = listOf( + BalanceDTO( + groupId = "groupId", + type = BalanceDTO.BalanceTypeDTO.UNKNOWN, + amount = BigDecimal.ONE, + date = null, + pricePerShare = BigDecimal.ZERO, + pendingActions = listOf(), + pendingActionConstraints = null, + tokenDTO = TokenDTO( + name = "The-Open-Network", + network = NetworkTypeDTO.TON, + symbol = "TON", + decimals = 8, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + validatorAddress = null, + validatorAddresses = null, + providerId = null, + ), + ), + integrationId = stakingId.integrationId, + ) + } + + fun createWithEmptyBalance(stakingId: StakingID = defaultStakingId): YieldBalanceWrapperDTO { + return YieldBalanceWrapperDTO( + addresses = Address(address = stakingId.address), + balances = emptyList(), + integrationId = stakingId.integrationId, + ) + } +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt new file mode 100644 index 0000000000..189705d948 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt @@ -0,0 +1,88 @@ +package com.tangem.common.test.data.staking + +import com.tangem.datasource.api.stakekit.models.response.model.AddressArgumentDTO +import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO +import com.tangem.domain.staking.model.StakingID +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockYieldDTOFactory { + + val defaultStakingID = StakingID( + integrationId = "ton-ton-chorus-one-pools-staking", + address = "0x1", + ) + + fun create(stakingID: StakingID = defaultStakingID): YieldDTO { + return YieldDTO( + id = stakingID.integrationId, + token = TokenDTO( + name = "Miguel Estes", + network = NetworkTypeDTO.POLYGON, + symbol = "splendide", + decimals = 1323, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + tokens = listOf(), + args = YieldDTO.ArgsDTO( + enter = YieldDTO.ArgsDTO.Enter( + addresses = YieldDTO.ArgsDTO.Enter.Addresses( + address = AddressArgumentDTO(required = false), + ), + args = mapOf(), + ), + exit = null, + ), + status = YieldDTO.StatusDTO(enter = true, exit = true), + apy = BigDecimal.ONE, + rewardRate = 1.0, + rewardType = YieldDTO.RewardTypeDTO.UNKNOWN, + metadata = YieldDTO.MetadataDTO( + name = "name", + logoUri = "logoUri", + description = "description", + documentation = null, + gasFeeTokenDTO = TokenDTO( + name = "Johnnie Mullen", + network = NetworkTypeDTO.POLYGON, + symbol = "fuisset", + decimals = 2957, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + tokenDTO = TokenDTO( + name = "Lazaro Wood", + network = NetworkTypeDTO.POLYGON, + symbol = "vocent", + decimals = 1602, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + tokensDTO = listOf(), + type = "type", + rewardSchedule = YieldDTO.MetadataDTO.RewardScheduleDTO.DAY, + cooldownPeriod = null, + warmupPeriod = YieldDTO.MetadataDTO.PeriodDTO(1), + rewardClaiming = YieldDTO.MetadataDTO.RewardClaimingDTO.AUTO, + defaultValidator = null, + minimumStake = null, + supportsMultipleValidators = null, + revshare = YieldDTO.MetadataDTO.EnabledDTO(enabled = true), + fee = YieldDTO.MetadataDTO.EnabledDTO(enabled = true), + ), + validators = listOf(), + isAvailable = true, + ) + } +} \ No newline at end of file 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 365267e1ba..4c16726aa0 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 @@ -23,7 +23,6 @@ object MockUserWalletFactory { return UserWallet( walletId = userWalletId, name = "Wallet 1", - artworkUrl = "", cardsInWallet = emptySet(), scanResponse = scanResponse, isMultiCurrency = scanResponse.cardTypesResolver.isMultiwalletAllowed(), diff --git a/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt b/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt index 537e01b8f3..81467b2b4b 100644 --- a/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt +++ b/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt @@ -1,19 +1,18 @@ package com.tangem.common.test.utils -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @OptIn(ExperimentalCoroutinesApi::class) -fun CoroutineScope.getEmittedValues(testScheduler: TestCoroutineScheduler, actual: Flow): List { +fun TestScope.getEmittedValues(flow: Flow): List { val values = mutableListOf() - launch(UnconfinedTestDispatcher(testScheduler)) { - actual.toList(values) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + flow.toList(values) } return values 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 c4a34ce6ee..b0d7438548 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 @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.CardColors import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,7 +61,7 @@ fun UserWalletItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - CardImage(imageUrl = state.imageUrl) + CardImage(state.imageState) NameAndInfo( modifier = Modifier.weight(1f), name = state.name, @@ -147,39 +147,49 @@ private fun NameAndInfo( } @Composable -private fun CardImage(imageUrl: String, modifier: Modifier = Modifier) { +private fun CardImage(imageState: UserWalletItemUM.ImageState, modifier: Modifier = Modifier) { val imageModifier = modifier .width(TangemTheme.dimens.size24) .height(TangemTheme.dimens.size36) .clip(TangemTheme.shapes.roundedCornersSmall) - SubcomposeAsyncImage( - modifier = imageModifier, - model = ImageRequest.Builder(LocalContext.current) - .transformations(RotationTransformation(angle = 90f)) - .size( - width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, - height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, - ) - .data(imageUrl) - .crossfade(enable = true) - .allowHardware(enable = false) - .build(), - loading = { + when (imageState) { + is UserWalletItemUM.ImageState.Loading -> { RectangleShimmer( modifier = imageModifier, radius = TangemTheme.dimens.size2, ) - }, - error = { - Image( + } + is UserWalletItemUM.ImageState.Image -> { + SubcomposeAsyncImage( modifier = imageModifier, - imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36), + model = ImageRequest.Builder(LocalContext.current) + .transformations(RotationTransformation(angle = 90f)) + .size( + width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, + height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, + ) + .data(imageState.artwork.verifiedArtwork?.toByteArray() ?: imageState.artwork.defaultUrl) + .crossfade(enable = true) + .allowHardware(enable = false) + .build(), + loading = { + RectangleShimmer( + modifier = imageModifier, + radius = TangemTheme.dimens.size2, + ) + }, + error = { + Image( + modifier = imageModifier, + imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36), + contentDescription = null, + ) + }, contentDescription = null, ) - }, - contentDescription = null, - ) + } + } } @Composable @@ -215,7 +225,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider { override fun convert(value: UserWallet): UserWalletItemUM { @@ -40,10 +43,12 @@ class UserWalletItemUMConverter( name = stringReference(name), information = getInfo(userWallet = this), balance = getBalanceInfo(userWallet = this), - imageUrl = artworkUrl, isEnabled = !isLocked, endIcon = endIcon, onClick = { onClick(value.walletId) }, + imageState = artwork?.let { + UserWalletItemUM.ImageState.Image(ArtworkUM(it.verifiedArtwork, it.defaultUrl)) + } ?: UserWalletItemUM.ImageState.Loading, ) } } 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 04b6329ce9..a986ba5b62 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 @@ -1,5 +1,6 @@ package com.tangem.common.ui.userwallet.state +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.wallets.models.UserWalletId import javax.annotation.concurrent.Immutable @@ -10,7 +11,7 @@ data class UserWalletItemUM( val name: TextReference, val information: TextReference, val balance: Balance, - val imageUrl: String, + val imageState: ImageState = ImageState.Loading, val isEnabled: Boolean, val endIcon: EndIcon = EndIcon.None, val onClick: () -> Unit, @@ -36,4 +37,14 @@ data class UserWalletItemUM( val isFlickering: Boolean, ) : Balance() } + + @Immutable + sealed class ImageState { + + data object Loading : ImageState() + + data class Image( + val artwork: ArtworkUM, + ) : ImageState() + } } \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt index 5d8c3a6887..879b87a6f9 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt @@ -22,9 +22,35 @@ sealed class MainScreenAnalyticsEvent( ) // region Action Buttons feature - data class ButtonBuy(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent( + data class ButtonBuy( + val status: AnalyticsParam.Status, + val screenType: String? = null, + ) : MainScreenAnalyticsEvent( event = "Button - Buy", - params = mapOf(AnalyticsParam.STATUS to status.value), + params = buildMap { + put(AnalyticsParam.STATUS, status.value) + screenType?.let { put(AnalyticsParam.TYPE, it) } + }, + ) + + data object ButtonReceive : MainScreenAnalyticsEvent( + event = "Button - Receive", + ) + + data object LimitsClicked : MainScreenAnalyticsEvent( + event = "Limits Clicked", + ) + + data object NoticeBalancesInfo : MainScreenAnalyticsEvent( + event = "Notice - Balances Info", + ) + + data object NoticeLimitsInfo : MainScreenAnalyticsEvent( + event = "Notice - Limits Info", + ) + + data object ButtonExplore : MainScreenAnalyticsEvent( + event = "Button - Explore", ) data class ButtonSwap(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent( @@ -83,4 +109,9 @@ sealed class MainScreenAnalyticsEvent( params = mapOf(ERROR_CODE to errorCode), ) // endregion + + companion object { + const val VISA_TYPE = "Visa" + const val WALLET_TYPE = "Wallet" + } } \ 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 86ddd48e06..109fa60297 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,6 +43,10 @@ "name": "NOTE_REFACTORING_ENABLED", "version": "5.23.0" }, + { + "name": "NEW_ARTWORK_LOADING", + "version": "5.24.0" + }, { "name": "NEW_ATTESTATION_ENABLED", "version": "undefined" @@ -62,5 +66,13 @@ { "name": "NETWORKS_LOADING_REFACTORING_ENABLED", "version": "5.23.0" + }, + { + "name": "QUOTES_LOADING_REFACTORING_ENABLED", + "version": "5.24.0" + }, + { + "name": "STAKING_LOADING_REFACTORING_ENABLED", + "version": "undefined" } ] 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 750dbada43..7aca4511ea 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 @@ -32,7 +32,7 @@ internal class DevFeatureTogglesManager( val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull>( key = PreferencesKeys.FEATURE_TOGGLES_KEY, - ) ?: emptyMap() + ) ?: emptyMap() val localFeatureToggles = localTogglesStorage.toggles .associateToggles(currentVersion = versionProvider.get().orEmpty()) 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 index b1822c84d9..a4c3d6d7aa 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -12,6 +13,7 @@ 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 @@ -24,7 +26,11 @@ import kotlin.collections.set internal class DevTogglesManagerTest { private val localTogglesStorage = mockk() - private val appPreferenceStore = mockk(relaxed = true) + private val appPreferenceStore = AppPreferencesStore( + moshi = Moshi.Builder().build(), + dispatchers = TestingCoroutineDispatcherProvider(), + preferencesDataStore = mockk(relaxed = true), + ) private val versionProvider = mockk() private val manager = DevFeatureTogglesManager( localTogglesStorage = localTogglesStorage, 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 index 4a8e6550b8..b2f5bc4be7 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -24,7 +25,11 @@ internal class LocalTogglesStorageTest { private val jsonAdapter = mockk>>() // Impossible to mockk AssetLoader because it implement inline functions - private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) + private val assetLoader = AssetLoader( + assetReader = assetReader, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) private val storage = LocalTogglesStorage(assetLoader) diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 0efd84702a..ebde9b43cd 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { /** Chucker */ debugImplementation(deps.chucker) mockedImplementation(deps.chuckerStub) + externalImplementation(deps.chuckerStub) internalImplementation(deps.chuckerStub) releaseImplementation(deps.chuckerStub) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/BlockAidApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/BlockAidApi.kt new file mode 100644 index 0000000000..605bc8a391 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/BlockAidApi.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.api.common.blockaid + +import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse +import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface BlockAidApi { + + @POST("site/scan") + suspend fun scanDomain(@Body request: DomainScanRequest): DomainScanResponse + + @POST("evm/json-rpc/scan") + suspend fun scanJsonRpc(@Body request: EvmTransactionScanRequest): TransactionScanResponse + + @POST("solana/message/scan") + suspend fun scanSolanaMessage(@Body request: SolanaTransactionScanRequest): TransactionScanResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/DomainScanRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/DomainScanRequest.kt new file mode 100644 index 0000000000..e88eca28ed --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/DomainScanRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.common.blockaid.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DomainScanRequest( + @Json(name = "url") val url: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/EvmTransactionScanRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/EvmTransactionScanRequest.kt new file mode 100644 index 0000000000..fd5aeecfaf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/EvmTransactionScanRequest.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.common.blockaid.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata + +@JsonClass(generateAdapter = true) +data class EvmTransactionScanRequest( + @Json(name = "chain") val chain: String, + @Json(name = "account_address") val accountAddress: String, + @Json(name = "method") val method: String, + @Json(name = "data") val data: RpcData, + @Json(name = "options") val options: List = listOf("simulation", "validation"), + @Json(name = "metadata") val metadata: TransactionMetadata, +) + +@JsonClass(generateAdapter = true) +data class RpcData( + @Json(name = "jsonrpc") val jsonrpc: String = "2.0", + @Json(name = "method") val method: String, + @Json(name = "params") val params: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/SolanaTransactionScanRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/SolanaTransactionScanRequest.kt new file mode 100644 index 0000000000..72ec506f01 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/SolanaTransactionScanRequest.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.api.common.blockaid.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata + +@JsonClass(generateAdapter = true) +data class SolanaTransactionScanRequest( + @Json(name = "encoding") val encoding: String = "base64", + @Json(name = "chain") val chain: String, + @Json(name = "method") val method: String, + @Json(name = "options") val options: List = listOf("simulation, validation"), + @Json(name = "metadata") val metadata: TransactionMetadata, + @Json(name = "account_address") val accountAddress: String, + @Json(name = "transactions") val transactions: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AccountSummaryResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AccountSummaryResponse.kt new file mode 100644 index 0000000000..e0188fa7dc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AccountSummaryResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AccountSummaryResponse( + @Json(name = "assets_diffs") val assetsDiffs: List, + @Json(name = "exposures") val exposures: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AssetDiff.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AssetDiff.kt new file mode 100644 index 0000000000..645409ec89 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AssetDiff.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AssetDiff( + @Json(name = "asset_type") val assetType: String, + @Json(name = "asset") val asset: Asset, + @Json(name = "in") val inTransfer: List? = null, + @Json(name = "out") val outTransfer: List? = null, +) + +@JsonClass(generateAdapter = true) +data class Asset( + @Json(name = "chain_id") val chainId: Int? = null, + @Json(name = "logo_url") val logoUrl: String? = null, + @Json(name = "symbol") val symbol: String, +) + +@JsonClass(generateAdapter = true) +data class Transfer( + @Json(name = "value") val value: String, + @Json(name = "raw_value") val rawValue: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/DomainScanResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/DomainScanResponse.kt new file mode 100644 index 0000000000..b11e7a6c88 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/DomainScanResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DomainScanResponse( + @Json(name = "status") val status: String, + @Json(name = "is_malicious") val isMalicious: Boolean?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Exposure.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Exposure.kt new file mode 100644 index 0000000000..90038d41c4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Exposure.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Exposure( + @Json(name = "asset") val asset: Asset, + @Json(name = "spenders") val spenders: Map, +) + +@JsonClass(generateAdapter = true) +data class SpenderDetails( + @Json(name = "exposure") val exposure: List, + @Json(name = "is_approved_for_all") val isApprovedForAll: Boolean? = null, +) + +@JsonClass(generateAdapter = true) +data class ExposureDetail( + @Json(name = "value") val value: String, + @Json(name = "raw_value") val rawValue: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/SimulationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/SimulationResponse.kt new file mode 100644 index 0000000000..a62c20d307 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/SimulationResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SimulationResponse( + @Json(name = "status") val status: String, + @Json(name = "account_summary") val accountSummary: AccountSummaryResponse, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionMetadata.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionMetadata.kt new file mode 100644 index 0000000000..13f229ead5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionMetadata.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TransactionMetadata( + @Json(name = "domain") val domain: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionScanResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionScanResponse.kt new file mode 100644 index 0000000000..9d45809013 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionScanResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TransactionScanResponse( + @Json(name = "validation") val validation: ValidationResponse, + @Json(name = "simulation") val simulation: SimulationResponse, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt new file mode 100644 index 0000000000..08e86a0697 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ValidationResponse( + @Json(name = "status") val status: String, + @Json(name = "result_type") val resultType: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 66924d6e19..78db8380c3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -27,6 +27,7 @@ sealed class ApiConfig { TangemVisaAuth, TangemVisa, TangemCardSdk, + BlockAid, } private fun initializeId(): ID { @@ -37,6 +38,7 @@ sealed class ApiConfig { is TangemVisaAuth -> ID.TangemVisaAuth is TangemVisa -> ID.TangemVisa is TangemCardSdk -> ID.TangemCardSdk + is BlockAid -> ID.BlockAid } } @@ -44,6 +46,7 @@ sealed class ApiConfig { internal const val DEBUG_BUILD_TYPE = "debug" internal const val INTERNAL_BUILD_TYPE = "internal" internal const val MOCKED_BUILD_TYPE = "mocked" + internal const val EXTERNAL_BUILD_TYPE = "external" internal const val RELEASE_BUILD_TYPE = "release" } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/BlockAid.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/BlockAid.kt new file mode 100644 index 0000000000..008cd9ef09 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/BlockAid.kt @@ -0,0 +1,27 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage +import com.tangem.utils.ProviderSuspend + +internal class BlockAid( + private val environmentConfigStorage: EnvironmentConfigStorage, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD + + override val environmentConfigs = listOf( + createProdEnvironment(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.blockaid.io/v0/", + headers = buildMap { + environmentConfigStorage.getConfigSync().blockAidApiKey?.let { apiKey -> + put("X-API-KEY", ProviderSuspend { apiKey }) + } + put("accept", ProviderSuspend { "application/json" }) + put("content-type", ProviderSuspend { "application/json" }) + }, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt index 3ad70b9000..db32d40409 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -48,10 +48,8 @@ internal class Express( private fun createHeaders(isProd: Boolean) = buildMap { put(key = "api-key", value = ProviderSuspend { getApiKey(isProd) }) - put(key = "user-id", value = ProviderSuspend(expressAuthProvider::getUserId)) put(key = "session-id", value = ProviderSuspend(expressAuthProvider::getSessionId)) putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider).values) - put(key = "refcode", value = ProviderSuspend(expressAuthProvider::getRefCode)) } private fun getApiKey(isProd: Boolean): String { @@ -73,6 +71,7 @@ internal class Express( INTERNAL_BUILD_TYPE, MOCKED_BUILD_TYPE, -> ApiEnvironment.STAGE + EXTERNAL_BUILD_TYPE, RELEASE_BUILD_TYPE, -> ApiEnvironment.PROD else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisa.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisa.kt index 9c1f78bb43..5310f5b308 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisa.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisa.kt @@ -15,7 +15,7 @@ internal class TangemVisa( private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://bff.tangem.com/", + baseUrl = "[REDACTED_ENV_URL]", headers = createHeaders(), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisaAuth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisaAuth.kt index e0a9068c02..833cde2111 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisaAuth.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemVisaAuth.kt @@ -15,7 +15,7 @@ internal class TangemVisaAuth( private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.STAGE, - baseUrl = "https://api-s.tangem.org/", + baseUrl = "[REDACTED_ENV_URL]", headers = createHeaders(), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt index 54a7935d66..469ab8a516 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt @@ -5,10 +5,7 @@ import com.tangem.datasource.api.express.models.request.AssetsRequestBody import com.tangem.datasource.api.express.models.request.ExchangeSentRequestBody import com.tangem.datasource.api.express.models.request.PairsRequestBody import com.tangem.datasource.api.express.models.response.* -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Query +import retrofit2.http.* /** * Interface of Tangem Express API (new swap mechanism) @@ -17,16 +14,29 @@ import retrofit2.http.Query interface TangemExpressApi { @POST("assets") - suspend fun getAssets(@Body body: AssetsRequestBody): ApiResponse> + suspend fun getAssets( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + @Body body: AssetsRequestBody, + ): ApiResponse> @POST("pairs") - suspend fun getPairs(@Body body: PairsRequestBody): ApiResponse> + suspend fun getPairs( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + @Body body: PairsRequestBody, + ): ApiResponse> @GET("providers") - suspend fun getProviders(): ApiResponse> + suspend fun getProviders( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + ): ApiResponse> @GET("exchange-quote") suspend fun getExchangeQuote( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, @Query("fromContractAddress") fromContractAddress: String, @Query("fromNetwork") fromNetwork: String, @Query("toContractAddress") toContractAddress: String, @@ -40,6 +50,8 @@ interface TangemExpressApi { @GET("exchange-data") suspend fun getExchangeData( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, @Query("fromContractAddress") fromContractAddress: String, @Query("fromNetwork") fromNetwork: String, @Query("toContractAddress") toContractAddress: String, @@ -57,8 +69,16 @@ interface TangemExpressApi { ): ApiResponse @GET("exchange-status") - suspend fun getExchangeStatus(@Query("txId") txId: String): ApiResponse + suspend fun getExchangeStatus( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + @Query("txId") txId: String, + ): ApiResponse @POST("exchange-sent") - suspend fun exchangeSent(@Body body: ExchangeSentRequestBody): ApiResponse + suspend fun exchangeSent( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + @Body body: ExchangeSentRequestBody, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt index 2a8034b8b1..433cf8b3eb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/OnrampApi.kt @@ -3,37 +3,52 @@ package com.tangem.datasource.api.onramp import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest import com.tangem.datasource.api.onramp.models.response.OnrampDataResponse -import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO import com.tangem.datasource.api.onramp.models.response.OnrampQuoteResponse import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO +import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Query +import retrofit2.http.* @Suppress("LongParameterList", "LargeClass", "TooManyFunctions") interface OnrampApi { @GET("currencies") - suspend fun getCurrencies(): ApiResponse> + suspend fun getCurrencies( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + ): ApiResponse> @GET("countries") - suspend fun getCountries(): ApiResponse> + suspend fun getCountries( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + ): ApiResponse> @GET("country-by-ip") - suspend fun getCountryByIp(): ApiResponse + suspend fun getCountryByIp( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + ): ApiResponse @GET("payment-methods") - suspend fun getPaymentMethods(): ApiResponse> + suspend fun getPaymentMethods( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + ): ApiResponse> @POST("onramp-pairs") - suspend fun getPairs(@Body body: OnrampPairsRequest): ApiResponse> + suspend fun getPairs( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + @Body body: OnrampPairsRequest, + ): ApiResponse> @GET("onramp-quote") suspend fun getQuote( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, @Query("fromCurrencyCode") fromCurrencyCode: String, @Query("fromPrecision") fromPrecision: Int, @Query("toContractAddress") toContractAddress: String, @@ -47,6 +62,8 @@ interface OnrampApi { @GET("onramp-data") suspend fun getData( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, @Query("fromCurrencyCode") fromCurrencyCode: String, @Query("fromPrecision") fromPrecision: Int, @Query("toContractAddress") toContractAddress: String, @@ -64,5 +81,9 @@ interface OnrampApi { ): ApiResponse @GET("onramp-status") - suspend fun getStatus(@Query("txId") txId: String): ApiResponse + suspend fun getStatus( + @Header("user-id") userWalletId: String, + @Header("refcode") refCode: String, + @Query("txId") txId: String, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampStatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampStatusResponse.kt index c701137912..6f5ec14d6a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampStatusResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampStatusResponse.kt @@ -91,4 +91,10 @@ enum class Status { @Json(name = "paused") Paused, + + @Json(name = "refund-in-progress") + RefundInProgress, + + @Json(name = "refunded") + Refunded, } \ No newline at end of file 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 c109e5b91e..7b474dd442 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 @@ -140,6 +140,40 @@ interface TangemTechApi { @GET("stories/{story_id}") suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse + // region push notifications + @GET("notification/push_notifications_eligible_networks") + suspend fun getEligibleNetworksForPushNotifications(): ApiResponse> + + @POST("user-wallets/applications/") + suspend fun createApplicationId( + @Body + body: NotificationApplicationCreateBody, + ): ApiResponse + + @PATCH("user-wallets/applications/{application_id}") + suspend fun updatePushTokenForApplicationId( + @Path("application_id") applicationId: String, + @Body body: NotificationApplicationCreateBody, + ): ApiResponse + + @PATCH("user-wallets/wallets/{wallet_id}/notify") + suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse + // endregion + + // region wallets + @PATCH("user-wallets/wallets/{wallet_id}") + suspend fun updateWallet(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse + + @POST("user-wallets/wallets/create-and-connect-by-appuid/{application_id}") + suspend fun associateApplicationIdWithWallets( + @Path("application_id") applicationId: String, + @Body body: List, + ): ApiResponse + + @GET("user-wallets/wallets/{wallet_id}") + suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse + // endregion + companion object { val marketsQuoteFields = listOf( "price", diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CryptoNetworkResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CryptoNetworkResponse.kt new file mode 100644 index 0000000000..610606eae8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CryptoNetworkResponse.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CryptoNetworkResponse( + @Json(name = "id") val id: Int, + @Json(name = "networkId") val networkId: String, + @Json(name = "name") val name: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationCreateBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationCreateBody.kt new file mode 100644 index 0000000000..fdc296f8c1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationCreateBody.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NotificationApplicationCreateBody( + @Json(name = "pushToken") val pushToken: String? = null, + @Json(name = "platform") val platform: String? = null, + @Json(name = "device") val device: String? = null, + @Json(name = "systemVersion") val systemVersion: String? = null, + @Json(name = "language") val language: String? = null, + @Json(name = "timezone") val timezone: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationIdResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationIdResponse.kt new file mode 100644 index 0000000000..4a2191fc4e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationIdResponse.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NotificationApplicationIdResponse( + @Json(name = "uid") val appId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationUpdateBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationUpdateBody.kt new file mode 100644 index 0000000000..ff5d0863cb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationUpdateBody.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NotificationApplicationUpdateBody( + @Json(name = "pushToken") val pushToken: String, + @Json(name = "systemVersion") val systemVersion: String? = null, + @Json(name = "language") val language: String? = null, + @Json(name = "timezone") val timezone: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt new file mode 100644 index 0000000000..6ad3704bfb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WalletBody( + @Json(name = "notifyStatus") val notifyStatus: Boolean? = null, + @Json(name = "name") val name: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt new file mode 100644 index 0000000000..dbadddd6fc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WalletIdBody( + @Json(name = "id") val walletId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletResponse.kt new file mode 100644 index 0000000000..63263f0a1d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletResponse.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WalletResponse( + @Json(name = "notifyStatus") val notifyStatus: Boolean, + @Json(name = "id") val id: String, + @Json(name = "name") val name: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaApi.kt index a0d08a74a1..16e643329b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaApi.kt @@ -1,9 +1,11 @@ package com.tangem.datasource.api.visa import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.utils.ReadTimeout import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest +import com.tangem.datasource.api.visa.models.request.ActivationStatusRequest +import com.tangem.datasource.api.visa.models.request.GetCardWalletAcceptanceRequest +import com.tangem.datasource.api.visa.models.request.GetCustomerWalletAcceptanceRequest import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest import com.tangem.datasource.api.visa.models.response.* import retrofit2.http.Body @@ -11,60 +13,40 @@ import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Query -import java.util.concurrent.TimeUnit interface TangemVisaApi { - @GET("product_instance/activation_status") + @POST("v1/activation/status") suspend fun getRemoteActivationStatus( @Header("Authorization") authHeader: String, - @Query("customer_id") customerId: String, - @Query("product_instance_id") productInstanceId: String, - @Query("card_id") cardId: String, - @Query("card_public_key") cardPublicKey: String, + @Body request: ActivationStatusRequest, ): ApiResponse - @ReadTimeout(duration = 20, TimeUnit.MINUTES) - @GET("product_instance/activation_status") - suspend fun getRemoteActivationStatusLongPoll( - @Header("Authorization") authHeader: String, - @Query("customer_id") customerId: String, - @Query("product_instance_id") productInstanceId: String, - @Query("card_id") cardId: String, - @Query("card_public_key") cardPublicKey: String, - ): ApiResponse - - @GET("product_instance/card_wallet_acceptance") + @POST("v1/activation/acceptance/message") suspend fun getCardWalletAcceptance( @Header("Authorization") authHeader: String, - @Query("customer_id") customerId: String, - @Query("product_instance_id") productInstanceId: String, - @Query("activation_order") activationOrderId: String, - @Query("customer_wallet_address") customerWalletAddress: String, - ): ApiResponse + @Body request: GetCardWalletAcceptanceRequest, + ): ApiResponse - @GET("product_instance/customer_wallet_acceptance") + @POST("v1/activation/acceptance/message") suspend fun getCustomerWalletAcceptance( @Header("Authorization") authHeader: String, - @Query("customer_id") customerId: String, - @Query("product_instance_id") productInstanceId: String, - @Query("activation_order") activationOrderId: String, - @Query("card_wallet_address") cardWalletAddress: String, - ): ApiResponse + @Body request: GetCustomerWalletAcceptanceRequest, + ): ApiResponse - @POST("product_instance/activation_by_card_wallet") + @POST("v1/activation/data") suspend fun activateByCardWallet( @Header("Authorization") authHeader: String, @Body body: ActivationByCardWalletRequest, ): ApiResponse - @POST("product_instance/activation_by_customer_wallet") + @POST("v1/activation/data") suspend fun activateByCustomerWallet( @Header("Authorization") authHeader: String, @Body body: ActivationByCustomerWalletRequest, ): ApiResponse - @POST("product_instance/issuer_activation") + @POST("v1/activation/pin") suspend fun setPinCode( @Header("Authorization") authHeader: String, @Body body: SetPinCodeRequest, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaAuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaAuthApi.kt index b2ed8d19be..57b91ca1c3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaAuthApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/TangemVisaAuthApi.kt @@ -1,32 +1,29 @@ package com.tangem.datasource.api.visa import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.visa.models.request.* import com.tangem.datasource.api.visa.models.response.GenerateNonceResponse import com.tangem.datasource.api.visa.models.response.JWTResponse -import retrofit2.http.Field +import retrofit2.http.Body import retrofit2.http.POST -import retrofit2.http.Query interface TangemVisaAuthApi { - @POST("auth/card_wallet") - suspend fun generateNonceByWalletAddress( - @Query("card_wallet_address") cardWalletAddress: String, - ): GenerateNonceResponse + @POST("v1/auth/challenge") + suspend fun generateNonceByCardId(@Body request: GenerateNoneByCardIdRequest): GenerateNonceResponse - @POST("auth/card_id") - suspend fun generateNonceByCard( - @Query("card_id") cardId: String, - @Query("card_public_key") cardPublicKey: String, - ): GenerateNonceResponse + @POST("v1/auth/challenge") + suspend fun generateNonceByCardWallet(@Body request: GenerateNoneByCardWalletRequest): GenerateNonceResponse - @POST("auth/get_token") - suspend fun getAccessToken( - @Query("session_id") sessionId: String, - @Query("signature") signature: String, - @Query("salt") salt: String?, - ): JWTResponse + @POST("v1/auth/token") + suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): JWTResponse - @POST("auth/refresh_token") - suspend fun refreshAccessToken(@Field("refresh_token") refreshToken: String): ApiResponse + @POST("v1/auth/token") + suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): JWTResponse + + @POST("v1/auth/token/refresh") + suspend fun refreshCardIdAccessToken(@Body request: RefreshTokenByCardIdRequest): ApiResponse + + @POST("v1/auth/token/refresh") + suspend fun refreshCardWalletAccessToken(@Body request: RefreshTokenByCardWalletRequest): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCardWalletRequest.kt index d19f87f592..c16198f0c6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCardWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCardWalletRequest.kt @@ -5,22 +5,15 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ActivationByCardWalletRequest( - @Json(name = "customer_id") val customerId: String, - @Json(name = "product_instance_id") val productInstanceId: String, - @Json(name = "activation_order_id") val activationOrderId: String, - @Json(name = "data") val data: Data, + @Json(name = "order_id") val orderId: String, + @Json(name = "card_wallet") val cardWallet: CardWallet, + @Json(name = "deploy_acceptance_signature") val deployAcceptanceSignature: String, + @Json(name = "otp") val otp: Otp, ) { - @JsonClass(generateAdapter = true) - data class Data( - @Json(name = "card_wallet") val cardWallet: CardWallet, - @Json(name = "otp") val otp: Otp, - ) - @JsonClass(generateAdapter = true) data class CardWallet( @Json(name = "address") val address: String, @Json(name = "card_wallet_confirmation") val cardWalletConfirmation: CardWalletConfirmation?, - @Json(name = "deploy_acceptance_signature") val deployAcceptanceSignature: String, ) @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCustomerWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCustomerWalletRequest.kt index 47bdbc4132..ace2090603 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCustomerWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationByCustomerWalletRequest.kt @@ -5,19 +5,12 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ActivationByCustomerWalletRequest( - @Json(name = "customer_id") val customerId: String, - @Json(name = "product_instance_id") val productInstanceId: String, - @Json(name = "activation_order_id") val activationOrderId: String, - @Json(name = "data") val data: Data, + @Json(name = "order_id") val orderId: String, + @Json(name = "customer_wallet") val customerWallet: CustomerWallet, ) { - @JsonClass(generateAdapter = true) - data class Data( - @Json(name = "customer_wallet") val customerWallet: CustomerWallet, - ) - @JsonClass(generateAdapter = true) data class CustomerWallet( - @Json(name = "address") val address: String, @Json(name = "deploy_acceptance_signature") val deployAcceptanceSignature: String, + @Json(name = "address") val customerWalletAddress: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationStatusRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationStatusRequest.kt new file mode 100644 index 0000000000..7ce0614abc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/ActivationStatusRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ActivationStatusRequest( + @Json(name = "card_id") val cardId: String, + @Json(name = "card_public_key") val cardPublicKey: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardIdRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardIdRequest.kt new file mode 100644 index 0000000000..d3aa5eaa50 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardIdRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GenerateNoneByCardIdRequest( + @Json(name = "auth_type") val authType: String = "card_id", + @Json(name = "card_id") val cardId: String, + @Json(name = "card_public_key") val cardPublicKey: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardWalletRequest.kt new file mode 100644 index 0000000000..172c39e727 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GenerateNoneByCardWalletRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GenerateNoneByCardWalletRequest( + @Json(name = "auth_type") val authType: String = "card_wallet", + @Json(name = "card_wallet_address") val cardWalletAddress: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardIdRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardIdRequest.kt new file mode 100644 index 0000000000..c212c81412 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardIdRequest.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GetAccessTokenByCardIdRequest( + @Json(name = "auth_type") val authType: String = "card_id", + @Json(name = "session_id") val sessionId: String, + @Json(name = "signature") val signature: String, + @Json(name = "salt") val salt: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardWalletRequest.kt new file mode 100644 index 0000000000..c80e97b0c9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetAccessTokenByCardWalletRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GetAccessTokenByCardWalletRequest( + @Json(name = "auth_type") val authType: String = "card_wallet", + @Json(name = "session_id") val sessionId: String, + @Json(name = "signature") val signature: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCardWalletAcceptanceRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCardWalletAcceptanceRequest.kt new file mode 100644 index 0000000000..dd084fa326 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCardWalletAcceptanceRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GetCardWalletAcceptanceRequest( + @Json(name = "type") val type: String = "card_wallet", + @Json(name = "customer_wallet_address") val customerWalletAddress: String, + @Json(name = "card_wallet_address") val cardWalletAddress: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCustomerWalletAcceptanceRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCustomerWalletAcceptanceRequest.kt new file mode 100644 index 0000000000..3948fc43b3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/GetCustomerWalletAcceptanceRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GetCustomerWalletAcceptanceRequest( + @Json(name = "type") val type: String = "customer_wallet", + @Json(name = "customer_wallet_address") val customerWalletAddress: String, + @Json(name = "card_wallet_address") val cardWalletAddress: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardIdRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardIdRequest.kt new file mode 100644 index 0000000000..f94a59a280 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardIdRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RefreshTokenByCardIdRequest( + @Json(name = "auth_type") val authType: String = "card_id", + @Json(name = "refresh_token") val refreshToken: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardWalletRequest.kt new file mode 100644 index 0000000000..108469e1b6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/RefreshTokenByCardWalletRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.visa.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RefreshTokenByCardWalletRequest( + @Json(name = "auth_type") val authType: String = "card_wallet", + @Json(name = "refresh_token") val refreshToken: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/SetPinCodeRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/SetPinCodeRequest.kt index c3a5290c8c..69e1a5ac08 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/SetPinCodeRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/request/SetPinCodeRequest.kt @@ -5,14 +5,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class SetPinCodeRequest( - @Json(name = "customer_id") val customerId: String, - @Json(name = "activation_order_id") val activationOrderId: String, - @Json(name = "product_instance_id") val productInstanceId: String, - @Json(name = "data") val data: Data, -) { - data class Data( - @Json(name = "session_key") val sessionKey: String, - @Json(name = "iv") val iv: String, - @Json(name = "encrypted_pin") val encryptedPin: String, - ) -} \ No newline at end of file + @Json(name = "order_id") val orderId: String, + @Json(name = "session_id") val sessionId: String, + @Json(name = "iv") val iv: String, + @Json(name = "pin") val pin: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt index e0ee6cc4f0..4134fa9c0e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt @@ -5,12 +5,21 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class CardActivationRemoteStateResponse( - @Json(name = "activation_status") val status: String, - @Json(name = "activation_order") val activationOrder: ActivationOrder?, + @Json(name = "result") val result: Result, ) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "status") val status: String, + @Json(name = "order") val activationOrder: ActivationOrder?, + @Json(name = "stepChangeCode") val stepChangeCode: Int?, + @Json(name = "updatedAt") val updatedAt: String?, + ) + @JsonClass(generateAdapter = true) data class ActivationOrder( @Json(name = "id") val id: String, + @Json(name = "customer_id") val customerId: String, @Json(name = "customer_wallet_address") val customerWalletAddress: String, + @Json(name = "card_wallet_address") val cardWalletAddress: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CustomerWalletDataToSignResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CustomerWalletDataToSignResponse.kt deleted file mode 100644 index e4575a1587..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CustomerWalletDataToSignResponse.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.datasource.api.visa.models.response - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class CustomerWalletDataToSignResponse( - @Json(name = "data_for_customer_wallet") val dataForCardWallet: Data, -) { - @JsonClass(generateAdapter = true) - data class Data( - @Json(name = "hash") val hash: String, - ) -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/GenerateNonceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/GenerateNonceResponse.kt index 002e43b16e..9a79a457b8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/GenerateNonceResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/GenerateNonceResponse.kt @@ -5,6 +5,11 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class GenerateNonceResponse( - @Json(name = "nonce") val nonce: String, - @Json(name = "session_id") val sessionId: String, -) \ No newline at end of file + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "nonce") val nonce: String, + @Json(name = "session_id") val sessionId: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/JWTResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/JWTResponse.kt index e4fc0d2579..0380074d02 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/JWTResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/JWTResponse.kt @@ -5,12 +5,17 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class JWTResponse( - @Json(name = "access_token") val accessToken: String, - @Json(name = "expires_in") val expiresIn: Int, - @Json(name = "refresh_expires_in") val refreshExpiresIn: Int, - @Json(name = "refresh_token") val refreshToken: String, - @Json(name = "token_type") val tokenType: String, - @Json(name = "not-before-policy") val notBeforePolicy: Int, - @Json(name = "session_state") val sessionState: String, - @Json(name = "scope") val scope: String, -) \ No newline at end of file + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "access_token") val accessToken: String, + @Json(name = "expires_in") val expiresIn: Int, + @Json(name = "refresh_expires_in") val refreshExpiresIn: Int, + @Json(name = "refresh_token") val refreshToken: String, + @Json(name = "token_type") val tokenType: String, + @Json(name = "not-before-policy") val notBeforePolicy: Int, + @Json(name = "session_state") val sessionState: String, + @Json(name = "scope") val scope: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardWalletDataToSignResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaDataToSignResponse.kt similarity index 66% rename from core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardWalletDataToSignResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaDataToSignResponse.kt index 288ab32e39..a19d3e7ca4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardWalletDataToSignResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/VisaDataToSignResponse.kt @@ -4,11 +4,11 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) -data class CardWalletDataToSignResponse( - @Json(name = "dataForCardWallet") val dataForCardWallet: Data, +data class VisaDataToSignResponse( + @Json(name = "result") val result: Result, ) { @JsonClass(generateAdapter = true) - data class Data( + data class Result( @Json(name = "hash") val hash: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt new file mode 100644 index 0000000000..5f9e2c12a9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.appcurrency + +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import kotlinx.coroutines.flow.Flow + +/** + * Store of app currency data model [CurrenciesResponse.Currency] + * +[REDACTED_AUTHOR] + */ +interface AppCurrencyResponseStore { + + /** Get flow of [CurrenciesResponse.Currency] */ + fun get(): Flow + + /** Get [CurrenciesResponse.Currency] synchronously or null */ + suspend fun getSyncOrNull(): CurrenciesResponse.Currency? +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt new file mode 100644 index 0000000000..1c268c06c7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt @@ -0,0 +1,28 @@ +package com.tangem.datasource.appcurrency + +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObject +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import kotlinx.coroutines.flow.Flow + +/** + * Default implementation of [AppCurrencyResponseStore] + * + * @property appPreferencesStore app preferences store + */ +internal class DefaultAppCurrencyResponseStore( + private val appPreferencesStore: AppPreferencesStore, +) : AppCurrencyResponseStore { + + override fun get(): Flow { + return appPreferencesStore.getObject(PreferencesKeys.SELECTED_APP_CURRENCY_KEY) + } + + override suspend fun getSyncOrNull(): CurrenciesResponse.Currency? { + return appPreferencesStore.getObjectSyncOrNull( + PreferencesKeys.SELECTED_APP_CURRENCY_KEY, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt b/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt index 6c04362a3c..3f47419a51 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt @@ -3,8 +3,10 @@ package com.tangem.datasource.asset.loader import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.squareup.moshi.adapter +import com.tangem.utils.coroutines.runCatching import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.di.NetworkMoshi +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -23,69 +25,63 @@ import javax.inject.Singleton class AssetLoader @Inject constructor( val assetReader: AssetReader, @NetworkMoshi val moshi: Moshi, + val dispatchers: CoroutineDispatcherProvider, ) { - /** Load content [Content] of asset file [fileName] */ @OptIn(ExperimentalStdlibApi::class) - suspend inline fun load(fileName: String): Content? { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun load(fileName: String): Content? = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - moshi.adapter().fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - null - }, - ) + moshi.adapter().fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + null + }, + ) /** Load list [V] values of asset file [fileName] */ - suspend inline fun loadList(fileName: String): List { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun loadList(fileName: String): List = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - val type = Types.newParameterizedType(List::class.java, V::class.java) - val adapter = moshi.adapter>(type) + val type = Types.newParameterizedType(List::class.java, V::class.java) + val adapter = moshi.adapter>(type) - adapter.fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig.orEmpty() - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - emptyList() - }, - ) + adapter.fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig.orEmpty() + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + emptyList() + }, + ) /** Load map [String] keys and [V] values of asset file [fileName] */ - suspend inline fun loadMap(fileName: String): Map { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun loadMap(fileName: String): Map = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - adapter.fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig.orEmpty() - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - emptyMap() - }, - ) + adapter.fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig.orEmpty() + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + emptyMap() + }, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt b/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt index 139e082aa9..3847d29980 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt @@ -1,23 +1,19 @@ package com.tangem.datasource.asset.reader import android.content.res.AssetManager -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext import java.io.BufferedReader /** * Implementation of asset file reader * * @property assetManager asset manager - * @property dispatchers dispatchers */ internal class AndroidAssetReader( private val assetManager: AssetManager, - private val dispatchers: CoroutineDispatcherProvider, ) : AssetReader { - override suspend fun read(fullFileName: String): String = withContext(dispatchers.io) { - assetManager.open(fullFileName).bufferedReader() + override suspend fun read(fullFileName: String): String { + return assetManager.open(fullFileName).bufferedReader() .use(BufferedReader::readText) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 5035774b7f..1a90974c75 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -46,6 +46,12 @@ internal object ApiConfigsModule { @IntoSet fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemVisa(appVersionProvider) + @Provides + @IntoSet + fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig { + return BlockAid(environmentConfigStorage) + } + @Provides @IntoSet fun provideTangemCardSdkConfig(): ApiConfig = TangemCardSdk() diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt index fade673123..70fb61dbea 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt @@ -1,8 +1,11 @@ package com.tangem.datasource.di +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.datasource.appcurrency.DefaultAppCurrencyResponseStore import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore import com.tangem.datasource.local.appcurrency.implementation.DefaultAvailableAppCurrenciesStore import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,4 +21,10 @@ internal object AppCurrencyDataModule { fun provideAvailableAppCurrenciesStore(): AvailableAppCurrenciesStore { return DefaultAvailableAppCurrenciesStore(dataStore = RuntimeDataStore()) } + + @Provides + @Singleton + fun provideAppCurrencyResponseStore(appPreferencesStore: AppPreferencesStore): AppCurrencyResponseStore { + return DefaultAppCurrencyResponseStore(appPreferencesStore) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt new file mode 100644 index 0000000000..c67a5baa1f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.info.AndroidAppInfoProvider +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.version.AppVersionProvider +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 AppInfoModule { + + @Singleton + @Provides + fun provideAppInfoProvider(appVersionProvider: AppVersionProvider): AppInfoProvider { + return AndroidAppInfoProvider(appVersionProvider) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt index bc295ea874..d572234840 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt @@ -2,7 +2,6 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi -import com.tangem.datasource.local.* import com.tangem.datasource.local.preferences.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -26,6 +25,7 @@ internal object AppPreferencesStoreModule { return AppPreferencesStore( preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io), moshi = moshi, + dispatchers = dispatchers, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt index 196ec8d836..3075e3f403 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt @@ -3,7 +3,6 @@ package com.tangem.datasource.di import android.content.Context import com.tangem.datasource.asset.reader.AndroidAssetReader import com.tangem.datasource.asset.reader.AssetReader -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,10 +16,7 @@ internal object AssetReaderModule { @Singleton @Provides - fun providesAsserReader( - @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, - ): AssetReader { - return AndroidAssetReader(context.assets, dispatchers) + fun providesAsserReader(@ApplicationContext context: Context): AssetReader { + return AndroidAssetReader(context.assets) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 700b510390..fad4677392 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -51,12 +51,14 @@ class MoshiModule { PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc") .withSubtype(NFTCollection.Identifier.EVM::class.java, "evm") .withSubtype(NFTCollection.Identifier.TON::class.java, "ton") + .withSubtype(NFTCollection.Identifier.Solana::class.java, "sol") .withDefaultValue(NFTCollection.Identifier.Unknown), ) .add( PolymorphicJsonAdapterFactory.of(NFTAsset.Identifier::class.java, "bc") .withSubtype(NFTAsset.Identifier.EVM::class.java, "evm") .withSubtype(NFTAsset.Identifier.TON::class.java, "ton") + .withSubtype(NFTAsset.Identifier.Solana::class.java, "sol") .withDefaultValue(NFTAsset.Identifier.Unknown), ) .addLast(KotlinJsonAdapterFactory()) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index dd14d5f63a..e05ca4cf1a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -4,6 +4,7 @@ import android.content.Context import com.squareup.moshi.Moshi import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfigs import com.tangem.datasource.api.common.config.managers.ApiConfigsManager @@ -282,6 +283,29 @@ internal object NetworkModule { .create(T::class.java) } + @Provides + @Singleton + fun provideBlockAidApi( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, + apiConfigsManager: ApiConfigsManager, + appLogsStore: AppLogsStore, + ): BlockAidApi { + return createApi( + id = ApiConfig.ID.BlockAid, + moshi = moshi, + context = context, + apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, + clientBuilder = { + addInterceptor( + NetworkLogsSaveInterceptor(appLogsStore), + ).applyTimeoutAnnotations() + }, + ) + } + private inline fun createApi( id: ApiConfig.ID, moshi: Moshi, diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt index dbcf34e870..48e2d14670 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import android.content.Context +import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi @@ -26,21 +27,27 @@ internal object QuotesStoreModule { @Provides @Singleton - fun provideQuotesStore( + fun providePersistenceQuotesStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, - ): QuotesStore { - return DefaultQuotesStore( - persistenceStore = DataStoreFactory.create( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = mapWithStringKeyTypes(), - defaultValue = emptyMap(), - ), - produceFile = { context.dataStoreFile(fileName = "quotes") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ): DataStore> { + return DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(), + defaultValue = emptyMap(), ), + produceFile = { context.dataStoreFile(fileName = "quotes") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + + @Provides + @Singleton + fun provideQuotesStore(persistenceStore: DataStore>): QuotesStore { + return DefaultQuotesStore( + persistenceStore = persistenceStore, runtimeStore = RuntimeSharedStore(), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt index 3d632eaf97..a631c02284 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import android.content.Context +import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi @@ -49,21 +50,29 @@ internal object StakingStoreModule { @Provides @Singleton - fun provideStakingBalanceStore( + fun provideYieldsBalancesPersistenceStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, + ): DataStore>> { + return DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(valueTypes = setTypes()), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "yield_balances") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + + @Provides + @Singleton + fun provideStakingBalanceStore( + persistenceStore: DataStore>>, ): StakingBalanceStore { return DefaultStakingBalanceStore( - persistenceStore = DataStoreFactory.create( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = mapWithStringKeyTypes(valueTypes = setTypes()), - defaultValue = emptyMap(), - ), - produceFile = { context.dataStoreFile(fileName = "yield_balances") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), - ), + persistenceStore = persistenceStore, runtimeStore = RuntimeSharedStore(), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt index b966a12e0e..c89a7f0d1d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt @@ -5,11 +5,14 @@ import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.request.AssetsRequestBody import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.express.models.response.Asset +import com.tangem.datasource.exchangeservice.swap.ExpressUtils.getRefCode +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.ExpressAssetsStore import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow @@ -33,31 +36,34 @@ typealias InitializationStatusFlow = MutableStateFlow internal class DefaultExpressServiceLoader @Inject constructor( private val tangemExpressApi: TangemExpressApi, private val expressAssetsStore: ExpressAssetsStore, + private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, ) : ExpressServiceLoader { private val initializationStatuses = MutableStateFlow>(value = emptyMap()) - override suspend fun update(userWalletId: UserWalletId, userTokens: List) { + override suspend fun update(userWallet: UserWallet, userTokens: List) { withContext(dispatchers.io) { - val initializationStatus = getInitializationStatusInternal(userWalletId) + val initializationStatus = getInitializationStatusInternal(userWallet.walletId) try { if (userTokens.isNotEmpty()) { val response = tangemExpressApi.getAssets( + userWalletId = userWallet.walletId.stringValue, + refCode = getRefCode(userWallet, appPreferencesStore), body = AssetsRequestBody(tokensList = userTokens), ).getOrThrow() - expressAssetsStore.store(userWalletId, response) + expressAssetsStore.store(userWallet.walletId, response) initializationStatus.update { response.lceContent() } } } catch (e: Throwable) { - if (expressAssetsStore.getSyncOrNull(userWalletId) == null) { + if (expressAssetsStore.getSyncOrNull(userWallet.walletId) == null) { initializationStatus.update { e.lceError() } } - Timber.e(e, "Unable to fetch assets for: ${userWalletId.stringValue}") + Timber.e(e, "Unable to fetch assets for: ${userWallet.walletId.stringValue}") } } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressServiceLoader.kt b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressServiceLoader.kt index f73ee82700..38d07ae3f1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressServiceLoader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressServiceLoader.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.exchangeservice.swap import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.express.models.response.Asset import com.tangem.domain.core.lce.Lce +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow @@ -13,8 +14,8 @@ import kotlinx.coroutines.flow.Flow */ interface ExpressServiceLoader { - /** Update service using [userWalletId] and [userTokens] */ - suspend fun update(userWalletId: UserWalletId, userTokens: List) + /** Update service using [userWallet] and [userTokens] */ + suspend fun update(userWallet: UserWallet, userTokens: List) /** Get initialization status by [userWalletId] */ fun getInitializationStatus(userWalletId: UserWalletId): Flow>> diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt new file mode 100644 index 0000000000..50d0877467 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt @@ -0,0 +1,46 @@ +package com.tangem.datasource.exchangeservice.swap + +import com.tangem.common.CardIdRangeDec +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.wallets.models.UserWallet +import kotlinx.coroutines.runBlocking + +object ExpressUtils { + private const val BATCH_ID_CHANGENOW = "BB000013" + private const val BATCH_ID_PARTNER = "AF990015" + + fun getRefCode(userWallet: UserWallet, appPreferencesStore: AppPreferencesStore): String { + return when { + isRing(userWallet, appPreferencesStore) -> "ring" + isChangeNow(userWallet) -> "ChangeNow" + isPartner(userWallet) -> "partner" + else -> "" + } + } + + private fun isRing(selectedUserWallet: UserWallet, appPreferencesStore: AppPreferencesStore): Boolean { + val addedWalletsWithRings = runBlocking { + appPreferencesStore.getSyncOrDefault( + key = PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY, + default = emptySet(), + ) + } + + return addedWalletsWithRings.contains(selectedUserWallet.walletId.stringValue) + } + + private fun isChangeNow(selectedUserWallet: UserWallet): Boolean { + val changeNowRange = CardIdRangeDec( + start = "AF99001800554008", + end = "AF99001800559994", + ) + val card = selectedUserWallet.scanResponse.card + return card.batchId == BATCH_ID_CHANGENOW || changeNowRange?.contains(card.cardId) == true + } + + private fun isPartner(selectedUserWallet: UserWallet): Boolean { + return selectedUserWallet.scanResponse.card.batchId == BATCH_ID_PARTNER + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt new file mode 100644 index 0000000000..08d960e333 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.info + +import android.os.Build +import com.tangem.utils.SupportedLanguages +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.version.AppVersionProvider +import java.util.* +import javax.inject.Inject + +internal class AndroidAppInfoProvider @Inject constructor( + private val appVersionProvider: AppVersionProvider, +) : AppInfoProvider { + override val platform: String + get() = "Android" + override val device: String + get() = "${Build.MANUFACTURER} ${Build.MODEL}" + override val osVersion: String + get() = Build.VERSION.RELEASE + override val language: String + get() = SupportedLanguages.getCurrentSupportedLanguageCode() + override val timezone: String + get() = TimeZone.getDefault().id + override val appVersion: String + get() = appVersionProvider.versionName +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index d4bc388d6d..9da0b616fc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -14,4 +14,5 @@ data class EnvironmentConfig( val express: ExpressModel? = null, val devExpress: ExpressModel? = null, val stakeKitApiKey: String? = null, + val blockAidApiKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt index 02d7967f47..80f41f68b3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt @@ -23,6 +23,7 @@ internal object EnvironmentConfigConverter : Converter maxSize) { - file.delete() + if (logFile.exists() && logFile.length() > maxSize) { + logFile.delete() } } } @@ -117,7 +121,7 @@ class AppLogsStore @Inject constructor( } private fun writeMessage(tag: String, vararg messages: String) { - BufferedWriter(FileWriter(file, true)).use { writer -> + BufferedWriter(FileWriter(logFile, true)).use { writer -> writer.append(formatter.print(DateTime.now())) writer.append(": $tag ") messages.forEach(writer::append) @@ -126,8 +130,8 @@ class AppLogsStore @Inject constructor( } private fun createFileIfNotExist() { - if (!file.exists()) { - runCatching { file.createNewFile() } + if (!logFile.exists()) { + runCatching { logFile.createNewFile() } .onFailure(Timber::e) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt index b6a045de7e..090e711d37 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt @@ -13,6 +13,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter NFTAsset.Identifier.TON( tokenAddress = value.tokenAddress, ) + is SdkNFTAsset.Identifier.Solana -> NFTAsset.Identifier.Solana( + tokenAddress = value.tokenAddress, + cnft = value.cnft, + ) is SdkNFTAsset.Identifier.Unknown -> NFTAsset.Identifier.Unknown } @@ -24,6 +28,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter SdkNFTAsset.Identifier.TON( tokenAddress = value.tokenAddress, ) + is NFTAsset.Identifier.Solana -> SdkNFTAsset.Identifier.Solana( + tokenAddress = value.tokenAddress, + cnft = value.cnft, + ) is NFTAsset.Identifier.Unknown -> SdkNFTAsset.Identifier.Unknown } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt index 98d8e0ee73..44f1df3f89 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt @@ -12,6 +12,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter NFTCollection.Identifier.TON( contractAddress = value.contractAddress, ) + is SdkNFTCollection.Identifier.Solana -> NFTCollection.Identifier.Solana( + collection = value.collection, + ) is SdkNFTCollection.Identifier.Unknown -> NFTCollection.Identifier.Unknown } @@ -22,6 +25,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter SdkNFTCollection.Identifier.TON( contractAddress = value.contractAddress, ) + is NFTCollection.Identifier.Solana -> SdkNFTCollection.Identifier.Solana( + collection = value.collection, + ) is NFTCollection.Identifier.Unknown -> SdkNFTCollection.Identifier.Unknown } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt index 905fe4f767..d30b6e20ef 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import com.tangem.utils.coroutines.CoroutineDispatcherProvider /** * Application preferences store. @@ -19,6 +20,7 @@ import com.squareup.moshi.Types */ class AppPreferencesStore( val moshi: Moshi, + val dispatchers: CoroutineDispatcherProvider, private val preferencesDataStore: DataStore, ) : DataStore by preferencesDataStore { 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 50275788dd..5177c8d4aa 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 @@ -123,6 +123,10 @@ object PreferencesKeys { val WALLETS_NFT_ENABLED_STATES_KEY by lazy { stringPreferencesKey(name = "walletsNftEnabledStates") } + // region Notifications + val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } + // endregion + fun getShouldShowStoriesKey(storyId: String) = booleanPreferencesKey("shouldShowStories_$storyId") // region Permission diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt index 0369cf22cd..647ff92c89 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt @@ -5,23 +5,25 @@ import androidx.datastore.preferences.core.edit import com.squareup.moshi.JsonDataException import com.squareup.moshi.Types import com.tangem.datasource.local.preferences.AppPreferencesStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext /** Get flow of nullable data [T] by string [key] */ inline fun AppPreferencesStore.getObject(key: Preferences.Key): Flow { - val adapter = moshi.adapter(T::class.java) - return data.map { preferences -> - preferences[key]?.let { - try { - adapter.fromJson(it) - } catch (e: JsonDataException) { - null - } - } - }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter(T::class.java) + emitAll( + data.map { preferences -> + preferences[key]?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } + } + }.distinctUntilChanged(), + ) + } } /** @@ -32,16 +34,19 @@ inline fun AppPreferencesStore.getObject(key: Preferences.Key AppPreferencesStore.getObject(key: Preferences.Key, default: T): Flow { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - return data.map { - try { - it[key]?.let(adapter::fromJson) ?: default - } catch (e: JsonDataException) { - default - } - }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter(T::class.java) + emitAll( + data.map { + try { + it[key]?.let(adapter::fromJson) ?: default + } catch (e: JsonDataException) { + default + } + }.distinctUntilChanged(), + ) + } } - /** * Get nullable data [T] by string [key] * @@ -49,26 +54,27 @@ inline fun AppPreferencesStore.getObject(key: Preferences.Key AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key): T? { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - return data.firstOrNull() - ?.get(key) - ?.let { - try { - adapter.fromJson(it) - } catch (e: JsonDataException) { - null +suspend inline fun AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key): T? = + withContext(dispatchers.io) { + val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types + data.firstOrNull() + ?.get(key) + ?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } } - } -} + } /** Get data [T] by string [key]. If data is not found, it returns [default] */ suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( key: Preferences.Key, default: T, -): T { +): T = withContext(dispatchers.io) { val adapter = moshi.adapter(T::class.java) - return data.firstOrNull() + data.firstOrNull() ?.get(key) ?.let { try { @@ -87,37 +93,47 @@ suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( * * @see storeObjectList * */ -suspend inline fun AppPreferencesStore.storeObject(key: Preferences.Key, value: T) { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - edit { it[key] = adapter.toJson(value) } -} +@Suppress("OptionalUnit") +suspend inline fun AppPreferencesStore.storeObject(key: Preferences.Key, value: T): Unit = + withContext(dispatchers.io) { + val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types + edit { it[key] = adapter.toJson(value) } + } /** Store list of data [value] by string [key] */ -suspend inline fun AppPreferencesStore.storeObjectList(key: Preferences.Key, value: List) { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - edit { it[key] = adapter.toJson(value) } -} +suspend inline fun AppPreferencesStore.storeObjectList(key: Preferences.Key, value: List) = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + edit { it[key] = adapter.toJson(value) } + } /** Get flow of list of data [T] by string [key]. If data is not found, it returns `null` */ inline fun AppPreferencesStore.getObjectList(key: Preferences.Key): Flow?> { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - return data.map { it[key]?.let(adapter::fromJson) }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + emitAll( + data.map { + it[key]?.let(adapter::fromJson) + }.distinctUntilChanged(), + ) + } } /** Get list of data [T] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectListSync(key: Preferences.Key): List { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} +suspend inline fun AppPreferencesStore.getObjectListSync(key: Preferences.Key): List = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Store map with [String] key and value [V] by string [key] */ suspend inline fun AppPreferencesStore.storeObjectMap( key: Preferences.Key, value: Map, -) { +) = withContext(dispatchers.io) { val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) val adapter = moshi.adapter>(type) @@ -125,37 +141,47 @@ suspend inline fun AppPreferencesStore.storeObjectMap( } /** Get map with [String] key and value [V] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectMapSync(key: Preferences.Key): Map { - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) +suspend inline fun AppPreferencesStore.getObjectMapSync(key: Preferences.Key): Map = + withContext(dispatchers.io) { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */ inline fun AppPreferencesStore.getObjectMap(key: Preferences.Key): Flow> { - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) + return flow { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - return data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() } + emitAll( + data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() }, + ) + } } /** Get set of data [T] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectSetSync(key: Preferences.Key): Set { - val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} +suspend inline fun AppPreferencesStore.getObjectSetSync(key: Preferences.Key): Set = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Get flow of set of [T] by string [key], or empty if data is not found */ inline fun AppPreferencesStore.getObjectSet(key: Preferences.Key): Flow> { - val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) - return data.map { - it[key]?.let(adapter::fromJson) ?: emptySet() + return flow { + val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) + emitAll( + data.map { + it[key]?.let(adapter::fromJson) ?: emptySet() + }, + ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt index a049816e5c..386887a13b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt @@ -22,7 +22,9 @@ internal class SharedPreferencesKeyMigration( private val keyName: String, ) : DataMigration { - private val legacyPrefs = context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) + private val legacyPrefs by lazy { + context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) + } override suspend fun cleanUp() { val sharedPrefsEditor = legacyPrefs.edit() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt index 120bea1730..54af10bcb2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt @@ -10,13 +10,24 @@ import com.tangem.utils.extensions.orZero /** * Converter from [QuotesResponse.Quote] to [Quote.Value] * - * @property isCached flag that determines whether the quote is a cache + * @property source status source * [REDACTED_AUTHOR] */ -internal class QuoteConverter(private val isCached: Boolean) : +class QuoteConverter( + private val source: StatusSource, +) : Converter, Quote.Value> { + /** + * Secondary constructor + * + * @param isCached flag that determines whether the quote is a cache + */ + constructor(isCached: Boolean) : this( + source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL, + ) + override fun convert(value: Map.Entry): Quote.Value { val (currencyId, quote) = value @@ -24,7 +35,7 @@ internal class QuoteConverter(private val isCached: Boolean) : rawCurrencyId = CryptoCurrency.RawID(currencyId), fiatRate = quote.price.orZero(), priceChange = quote.priceChange24h.orZero().movePointLeft(2), - source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL, + source = source, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt index 874e884f87..73800ea6dd 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt @@ -4,6 +4,8 @@ import androidx.datastore.core.DataStore import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds internal class DefaultStakingYieldsStore( private val dataStore: DataStore>, @@ -17,9 +19,18 @@ internal class DefaultStakingYieldsStore( return dataStore.data.firstOrNull().orEmpty() } + override suspend fun getSyncWithTimeout(): List? { + return withTimeoutOrNull(timeout = YIELDS_WAITING_TIMEOUT, block = { get().firstOrNull() }) + } + override suspend fun store(items: List) { dataStore.updateData { _ -> items } } + + private companion object { + + val YIELDS_WAITING_TIMEOUT = 15.seconds + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingYieldsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingYieldsStore.kt index 428aa1986c..29d2e37b01 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingYieldsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/StakingYieldsStore.kt @@ -9,5 +9,7 @@ interface StakingYieldsStore { suspend fun getSync(): List + suspend fun getSyncWithTimeout(): List? + suspend fun store(items: List) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt index 051e63780e..de55373f39 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt @@ -7,7 +7,7 @@ import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceItem import com.tangem.utils.converter.Converter -internal class YieldBalanceConverter( +class YieldBalanceConverter( private val source: StatusSource, ) : Converter { 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 56acd23a53..df1e2e84bd 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 @@ -13,7 +13,7 @@ interface UserWalletsStore { fun getSyncOrNull(key: UserWalletId): UserWallet? - suspend fun getSyncStrict(key: UserWalletId): UserWallet + fun getSyncStrict(key: UserWalletId): UserWallet suspend fun getAllSyncOrNull(): List? diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index d4158d4965..2624ca1707 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -6,6 +6,7 @@ import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.config.* import com.tangem.datasource.api.common.config.ApiConfig.Companion.DEBUG_BUILD_TYPE +import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfig.Companion.INTERNAL_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE @@ -49,9 +50,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { @Before fun setup() { every { appVersionProvider.versionName } returns VERSION_NAME - every { expressAuthProvider.getUserId() } returns EXPRESS_USER_ID every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID - every { expressAuthProvider.getRefCode() } returns EXPRESS_REF_CODE every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY every { appAuthProvider.getCardId() } returns APP_CARD_ID every { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY @@ -90,6 +89,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { is TangemVisaAuth -> createVisaAuthModel() is TangemVisa -> createVisaModel() is TangemCardSdk -> createTangemCardSdkModel() + is BlockAid -> createBlockAidSdkModel() } } @@ -100,6 +100,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { INTERNAL_BUILD_TYPE, MOCKED_BUILD_TYPE, -> ApiEnvironment.STAGE + EXTERNAL_BUILD_TYPE, RELEASE_BUILD_TYPE, -> ApiEnvironment.PROD else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") @@ -115,6 +116,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { INTERNAL_BUILD_TYPE, MOCKED_BUILD_TYPE, -> "[REDACTED_ENV_URL]" + EXTERNAL_BUILD_TYPE, RELEASE_BUILD_TYPE, -> "https://express.tangem.com/v1/" else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") @@ -127,9 +129,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { MockEnvironmentConfigStorage.EXPRESS_DEV_API_KEY } }, - "user-id" to ProviderSuspend { EXPRESS_USER_ID }, "session-id" to ProviderSuspend { EXPRESS_SESSION_ID }, - "refcode" to ProviderSuspend { EXPRESS_REF_CODE }, "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, @@ -182,7 +182,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { id = ApiConfig.ID.TangemVisaAuth, expected = ApiEnvironmentConfig( environment = ApiEnvironment.STAGE, - baseUrl = "https://api-s.tangem.org/", + baseUrl = "[REDACTED_ENV_URL]", headers = mapOf( "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, @@ -196,7 +196,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { id = ApiConfig.ID.TangemVisa, expected = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://bff.tangem.com/", + baseUrl = "[REDACTED_ENV_URL]", headers = mapOf( "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, @@ -215,6 +215,16 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { ) } + private fun createBlockAidSdkModel(): Model { + return Model( + id = ApiConfig.ID.BlockAid, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.blockaid.io/v0/", + ), + ) + } + private fun String.checkHeaderValueOrEmpty(): String { for (i in this.indices) { val c = this[i] diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt index 49924b9fe8..1bbee64573 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt @@ -7,6 +7,7 @@ import com.squareup.moshi.Types import com.squareup.moshi.adapter import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.asset.reader.AssetReader +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery import io.mockk.coVerifyOrder import io.mockk.every @@ -22,7 +23,11 @@ class AssetLoaderTest { private val assetReader = mockk() private val moshi = mockk() - private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) + private val assetLoader = AssetLoader( + assetReader = assetReader, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) @Test fun load() = runTest { diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt index c1beef670e..651d234c8d 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt @@ -2,7 +2,6 @@ package com.tangem.datasource.asset.reader import android.content.res.AssetManager import com.google.common.truth.Truth -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -15,7 +14,7 @@ import java.io.IOException internal class AndroidAssetReaderTest { private val assetManager = mockk() - private val assetReader = AndroidAssetReader(assetManager, TestingCoroutineDispatcherProvider()) + private val assetReader = AndroidAssetReader(assetManager) @Test fun read_content() = runTest { diff --git a/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/BuyCurrencyDeepLink.kt b/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/BuyCurrencyDeepLink.kt index 748afda798..458c57088f 100644 --- a/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/BuyCurrencyDeepLink.kt +++ b/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/BuyCurrencyDeepLink.kt @@ -3,7 +3,7 @@ package com.tangem.core.deeplink.global import com.tangem.core.deeplink.DeepLink class BuyCurrencyDeepLink( - val onReceive: (externalTxId: String) -> Unit, + val onReceive: (txId: String) -> Unit, ) : DeepLink() { override val uri = BUY_REDIRECT_DEEPLINK diff --git a/core/res/src/main/res/values-de/strings-blockchain.xml b/core/res/src/main/res/values-de/strings-blockchain.xml deleted file mode 100644 index adea1fc8ea..0000000000 --- a/core/res/src/main/res/values-de/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Standard - Altbestand - Erhalt der Gebühr fehlgeschlagen - Aufgrund der Beschränkungen von %1$s können nur %2$d UTXOs in eine einzige Transaktion passen. Das bedeutet, dass du nur %3$s oder weniger senden kannst. Du musst den Betrag reduzieren. - Nicht genug Geld für die Transaktion. Bitte lade dein Konto auf. - Es ist ein Fehler aufgetreten. Code: %s. - Um das %1$s -Netzwerk nutzen zu können, must du die Kontoreserve ( %2$s %3$s ) bezahlen, die diesen Betrag auf unbestimmte Zeit sperrt und verbirgt - Das Zielkonto ist nicht aktiv. Sende %s oder mehr, um das Konto zu aktivieren. - Senden Sie Geld an diese Andresse um ein Konto zu erstellen - Minimaler Betrag ist %s - Die Veränderung ist zu gering - Falsche Gebühr - Mindestguthaben ist %s - Das Zielkonto ist nicht erstellt. Der abzusendende Betrag soll %s + Gebühr oder mehr sein - Unbekannter Fehler - Der Betrag geht über die Bilanz hinaus - Ungültiger Betrag - Die Gebühr geht über die Bilanz hinaus - Der Gesamtbetrag geht über die Bilanz hinaus - Nein, alles senden - Um %s XTZ reduziert - Damit Sie beim nächsten Aufladen Ihrer Brieftasche keine erhöhte Provision zahlen, soll der Betrag um %s XTZ reduziert werden - diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index b7a04e5add..67aa4da4df 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -21,6 +21,8 @@ Token anlegen Token verwalten Sende nur %1$s ( %2$s ) vom %3$s -Netzwerk an diese Adresse. Die Verwendung anderer Token und Netzwerke kann zum Verlust von Geldern führen. + Standard + Altbestand So scannt man Hilfe anfordern Erneut versuchen @@ -83,6 +85,7 @@ Cardano-Transaktionsanforderungen Um eine %1$s Transaktion durchzuführen, musst du einen gewissen ADA-Betrag einzahlen, um die Netzwerkgebühr und den ADA-Mindestwert zu decken (5 ADA werden empfohlen). Nicht genügend ADA für die Tokenübertrag + Betrag absenden, die Änderung darf nicht unter 1 ADA liegen Du musst einige ADA verwalten, weil du einige Token auf der Cardano-Blockchain besitzt Nicht genug ADA Akzeptieren @@ -100,6 +103,7 @@ Saldo biometrische Authentifizierung biometrische Daten + Transaktion fehlgeschlagen Kaufen Gehe zu %1$s D hast keinen Zugang zur Kamera erteilt, bitte passe deine Datenschutzeinstellungen an @@ -110,6 +114,8 @@ Stakingbelohnungen beanspruchen Schließen Bestätigen + Kontakt zum Tangem-Support + Kontakt zum Visa-Support Weiter Kopieren Adresse kopieren @@ -135,6 +141,7 @@ Erkunden Transaktionsverlauf einsehen Explorer + Erhalt der Gebühr fehlgeschlagen Netzwerkgebühren sind Gebühren, die Nutzer für die Verarbeitung und Bestätigung von Transaktionen zahlen. Die Höhe der Gebühren kann von der Überlastung des Netzes, der Größe der Transaktion und der Ausführungspriorität abhängen. %s Schnell Markt @@ -156,6 +163,7 @@ Netzgebühr Der überwiesene Betrag wird um %1$s (%2$s) gekürzt, um die gewählte Gebührenhöhe zu decken. Weiter + NFT Nein Keine Adresse Jetzt @@ -178,10 +186,12 @@ Suchen Token suchen sek. + Alles einblenden Seed phrase Aktion auswählen Verkaufen Senden + Absenden der Transaktion fehlgeschlagen Der Server ist nicht verfügbar. Bitte versuche es später erneut. Teilen Link teilen @@ -205,6 +215,7 @@ Es ist ein Fehler aufgetreten. Bitte versuche es erneut. Nicht erreichbar Staking beenden + Aufgrund der Beschränkungen von %1$s können nur %2$d UTXOs in eine einzige Transaktion passen. Das bedeutet, dass du nur %3$s oder weniger senden kannst. Du musst den Betrag reduzieren. woche mit Ja @@ -261,6 +272,7 @@ Überprüfe deine Internetverbindung oder wechseln zu einem anderen Netzwerk Nutzungsbedingungen Du hast eine Karte oder Ring aus einer anderen Wallet verwendet. Tippe auf die Karte oder Ring, die dieser Wallet zugeordnet ist. + Nicht genug Geld für die Transaktion. Bitte lade dein Konto auf. Meine Token Du hast noch keine Token hinzugefügt. Füge Token über den Markt zum Tausch hinzu Kann nicht gegen %s ausgetauscht werden @@ -340,6 +352,9 @@ Feedback zu Tangem Eine Transaktion kann nicht gesendet werden Fehler in der Coinbeschreibung + Es ist ein Fehler aufgetreten + Es ist ein Fehler aufgetreten. Code: %s. + Memo erforderlich Nur diese Gib das Genehmigungslimit für das ausgewählte Token an Betrag %s @@ -503,10 +518,34 @@ %d Stück %d Stücke + NFTs, die an Deine Wallet-Adresse gesendet werden, werden hier angezeigt. + Noch keine Kollektionen + NFT erhalten + NFT-Kollektionen + Einige Daten werden möglicherweise nicht geladen + Vorübergehende Ladeprobleme + Basisinformationen + Netzwerk + Vertragsadresse + Letzter Verkaufspreis + Raritätsetikett + Seltenheitsgrad + Token-Adresse + Token-ID + Token-Standard + Eigenschaften + Keine Ergebnisse. Bitte versuche eine andere Anfrage. + Netzwerk auswählen + NFT erhalten + Eigenschaften %1$d NFTs in der %2$d Sammlung Tippe hier, um das erste NFT zu erhalten NFT-Sammlungen Die Daten konnten nicht geladen werden + Um das %1$s -Netzwerk nutzen zu können, must du die Kontoreserve ( %2$s %3$s ) bezahlen, die diesen Betrag auf unbestimmte Zeit sperrt und verbirgt + Das Zielkonto ist nicht aktiv. Sende %s oder mehr, um das Konto zu aktivieren. + Senden Sie Geld an diese Andresse um ein Konto zu erstellen + Das Zielkonto verfügt nicht über eine Vertrauensstellung für das gesendete Asset. Du musst einen einzigen Zugangscode einrichten, um alle deine Geräte zu schützen Schützen Du kannst später auf jeder Karte oder Ring einen individuellen Zugangscode einrichten @@ -522,7 +561,7 @@ Bitte wiederhole den Vorgang. Die Karte oder Ring wird auf Werkseinstellungen zurückgesetzt. Aktivierungsfehler Token hinzufügen - Du hast einee Backup-Karte oder einen Backup-Ring hinzugefügt. Wenn der Backup-Prozess abgeschlossen ist, kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du noch eine Karte oder einen Ring hast, fügen diese(n) zum Backup hinzu. Möchtest Du den Backup-Prozess fortsetzen? + Du hast eine Backup-Karte oder einen Backup-Ring hinzugefügt. Nach Abschluss des Backup-Vorgangs kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du eine weitere Karte oder einen weiteren Ring hast, fügen diesen dem Backup jetzt hinzu. Möchtest Du den Backup-Vorgang fortsetzen? Der Sicherungsvorgang ist teilweise abgeschlossen. Du kannst ihn jetzt nicht beenden. Die Passphrase ist eine fortschrittliche Sicherheitsfunktion, die von Krypto-Wallets verwendet wird. Sie fügt ein zusätzliches Wort oder eine Phrase deiner Wahl zu der bereits bestehenden Wiederherstellungsphrase hinzu, um einen brandneuen Satz von Adressen zu erzeugen. Hinzufügen einer Sicherungskarte oder Ring @@ -638,6 +677,7 @@ Kamerazugriff verweigert Kein Memo erforderlich %1$s ( %2$s ) im %3$s Netzwerk + %1$s im %2$s Netzwerk Das Senden einer anderen Währung führt zu deren irreversiblem Verlust. Sende nur %s an diese Adresse. Der Versand einer anderen Währung führt zu ihrem unwiderruflichen Verlust. Senden nur %1$s im Netzwerk %2$s @@ -716,6 +756,12 @@ Ziel-Tag Adresse eingeben Die Adresse stimmt mit der Adresse Ihrer Brieftasche überein + Minimaler Betrag ist %s + Minimaler Wechselgeld ist %s + Falsche Gebühr + Mindestguthaben ist %s + Das Zielkonto ist nicht erstellt. Der abzusendende Betrag soll %s + Gebühr oder mehr sein + Unbekannter Fehler Ungültiges Memo. Sie wird der Transaktion nicht hinzugefügt. Memo Überprüfe deine Netzwerkverbindung @@ -773,8 +819,12 @@ inklusive einer Netzgebühr von %1$s Die Transaktion wurde erfolgreich signiert und an den Blockchain-Knoten gesendet. Die Walletbilanz wird aktualisiert %1$s ist ein Vermögenswert im Tron-Netzwerk. Um die Gebühr zu berechnen und eine Transaktion durchzuführen, musst du etwas Tron (TRX) auf deinem Konto einzahlen. + Der Betrag geht über die Bilanz hinaus Um diese Transaktion für die angegebene Adresse abzuschließen, ist ein Ziel-Tag (Memo) erforderlich. Ziel-Tag erforderlich + Ungültiger Betrag + Die Gebühr geht über die Bilanz hinaus + Der Gesamtbetrag geht über die Bilanz hinaus Transaktion gesendet Bereite das Scannen der Karte oder Ring vor, die du einrichten möchtest. Entferne diese Wallet @@ -1022,6 +1072,7 @@ Das wird nicht lange dauern. Wir richten gerade Dein Konto ein. Das wird nicht lange dauern. Wir schließen die Aktivierung ab. Alles wird vorbereitet! + PIN wurde nicht akzeptiert. Versuche es erneut oder verwende einen anderen Code. Ungültige PIN: Sequenzen oder Wiederholungen vermeiden Schon kann es losgehen! Du kannst Deine Verbindung \n auf der Website des Drittanbieters \n herstellen und zurück zur Tangem-App gelangen @@ -1030,6 +1081,7 @@ Willkommen zurück! Folge den Schritten, um Dein Konto einzurichten. Willkommen! + Diese Transaktion anfechten Freischalten Scanne Deine Karte, um den Zugriff freizuschalten Benötigte Freischaltung @@ -1165,6 +1217,7 @@ Der Kontostand ist möglicherweise veraltet. Aktualisiere bittre die Seite. Verbindungen Alle trennen + Text über die Trennung aller dApps Alle dApps trennen Neue Verbindung Verbinde Deine Wallet mit einer anderen dApp @@ -1180,4 +1233,7 @@ Karte oder Ring scannen Verwenden %s oder scanne eine Karte oder Ring, um auf die App zuzugreifen Willkommen zurück! + Nein, alles senden + Um %s XTZ reduziert + Damit Sie beim nächsten Aufladen Ihrer Brieftasche keine erhöhte Provision zahlen, soll der Betrag um %s XTZ reduziert werden diff --git a/core/res/src/main/res/values-es/strings-blockchain.xml b/core/res/src/main/res/values-es/strings-blockchain.xml deleted file mode 100644 index 709f3ab9dd..0000000000 --- a/core/res/src/main/res/values-es/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Por defecto - Legado - No se pudo obtener la tarifa - 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. - No hay fondos suficientes para la transacción. Por favor, recargue su cuenta. - Ocurrió un error. Código: %s - Para utilizar la red %1$s, debe pagar la reserva de cuenta (%2$s%3$s), que bloquea y oculta ese monto 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 - El monto mínimo es %s - La cantidad es demasiado baja - 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 - Error desconocido - El monto excede el saldo - Cantidad no válida - La tarifa excede el saldo - El monto total excede el saldo - 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 - diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index abe6273ee4..8d8cd1ccfe 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -21,6 +21,8 @@ Agregue un token personalizado 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 Cómo escanear Solicitar soporte Inténtelo de nuevo @@ -83,6 +85,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 Deba mantener algo de ADA porque tiene algunos tokens en la blockchain de Cardano ADA insuficiente Aceptar @@ -100,6 +103,7 @@ Saldo autenticación biométrica biometría + No se pudo crear la transacción Comprar Ir a %1$s No ha otorgado acceso a su cámara, cambie su configuración de privacidad @@ -135,6 +139,7 @@ Explore Explore historial de transacciones Explorador(a) + No se pudo obtener la tarifa Las tarifas de red son cargos que los usuarios pagan para procesar y confirmar transacciones. El importe de la tarifa puede verse afectado por la congestión de la red, el tamaño de la transacción y la prioridad de ejecución. %s Rápido Mercado @@ -182,6 +187,7 @@ Seleccione una acción Vender Enviar + Error al enviar la transacción El servidor no está disponible, por favor inténtelo de nuevo más tarde Compartir Compartir enlace @@ -205,6 +211,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. semana con @@ -261,6 +268,7 @@ Compruebe su conexión a internet o cambia a una red diferente Condiciones de uso Ha usado una tarjeta de otra billetera. Toque la tarjeta asociada con esta billetera + No hay fondos suficientes para la transacción. Por favor, recargue su cuenta. Mis tokens Aún no ha añadido ningún token. Agregue tokens a través de Market para hacer swap No se puede intercambiar por %s @@ -340,6 +348,9 @@ Comentarios en Tangem No se puede completar una transacción Error de descripción de moneda + Ocurrió un error + Ocurrió un error. Código: %s + Requiere memo Esta transacción Especifique el límite aprobado para el token seleccionado. Montante %s @@ -499,6 +510,10 @@ Tire hacia arriba o toque la barra de búsqueda para agregar tokens directamente desde el mercado Agregar tokens NFC no está disponible en su dispositivo + Para utilizar la red %1$s, debe pagar la reserva de cuenta (%2$s%3$s), que bloquea y oculta ese monto 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. 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 @@ -708,6 +723,12 @@ ID de destino Introduce la dirección La dirección es la misma que su billetera. + El monto mínimo 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 + Error desconocido Memo no válido. No se añadirá a la transacción. Memo Compruebe su conexión de red @@ -765,8 +786,12 @@ incluida una tarifa de red de %1$s 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 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 Transacción enviada Escanee la tarjeta/anillo que quiere configurar Olvidar la billetera @@ -1144,4 +1169,7 @@ Escanee la tarjeta o el anillo 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 diff --git a/core/res/src/main/res/values-fr/strings-blockchain.xml b/core/res/src/main/res/values-fr/strings-blockchain.xml deleted file mode 100644 index 8269ca16c6..0000000000 --- a/core/res/src/main/res/values-fr/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Défaut - Héritage - Échec de réception des frais de commissions - En raison d\'une limitations sur les %1$s, seuls les %2$d UTXO peuvent s\'intégrer dans une seule transaction. Ce qui signifie vous ne pouvez envoyer que %3$s ou moins. Réduisez le montant. - Pas assez de fonds pour la transaction. Veuillez recharger votre compte. - Une erreur s\'est produite. Code:%s - Pour utiliser le réseau %1$s, vous devez payer la réserve de compte (%2$s%3$s), qui bloque et cache ce montant indéfiniment - Le compte de destination est inactif. Envoyez %s ou plus pour activer le compte. - Pour créer un compte, envoyez des fonds à cette adresse - Le montant minimal est de %s - Le montant est trop faible - Frais de commissions non valides - Le solde minimal est de %s - Le compte cible n\'a pas été créé. Le montant à envoyer doit être de %s + frais de commissions ou plus - Erreur inconnue - Le montant dépasse le solde - Montant invalide - Les frais de commissions dépassent le solde - Le total dépasse le solde - Non, envoyer toute la somme - Réduire de %s XTZ - Pour ne pas payer un fraid de commissions élevé la prochaine fois que vous rechargez votre portefeuille, veuillez réduire le montant de %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 01f05ff01c..e7209c6305 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -21,6 +21,8 @@ Ajouter un jeton personnalisé Gérer les jetons Envoyez uniquement %1$s (%2$s) depuis les réseaux %3$s à cette adresse. L\'utilisation d\'autres jetons et réseaux peut entraîner une perte de fonds. + Défaut + Héritage Comment scanner Demander de l\'aide Réessayez @@ -83,6 +85,7 @@ Exigences pour les transactions Cardano Pour effectuer une transaction %1$s, vous devez déposer de l\'ADA pour couvrir les frais de réseau et la valeur minimale en ADA (5 ADA recommandés) ADA insuffisant pour le transfert de jetons + Le montant envoyé et la change ne peuvent être inférieurs à 1 ADA Vous devez conserver un peu d\'ADA car vous avez des jetons sur la blockchain Cardano ADA insuffisant Accepter @@ -100,6 +103,7 @@ Solde authentification biométrique biométrie + Échec de création de la transaction Acheter Aller à %1$s Vous n\'avez pas octroyé l\'accès à votre caméra, veuillez modifier vos paramètres de confidentialité @@ -135,11 +139,13 @@ Explorez Explorez l\'historique des transactions Explorateur + Échec de réception des frais de commissions Les frais de réseau sont des charges que les utilisateurs paient pour traiter et confirmer les transactions. Le montant des frais peut être affecté par la congestion du réseau, la taille de la transaction et la priorité d\'exécution. %s Rapide Marché Lent Vitesse et frais + Terminer Synchroniser les adresses Aller au fournisseur Aller au jeton @@ -159,6 +165,7 @@ Aucune adresse Maintenant OK + Ouvrir dans le navigateur Carte principale Bague principale Passphrase @@ -180,8 +187,10 @@ Sélectionnez une action Vendre Envoyer + Échec d\'envoi de la transaction Le serveur n\'est pas disponible, veuillez réessayer plus tard Partager + Partager le lien Signez Signez et envoyez Stake @@ -202,6 +211,7 @@ Il y avait une erreur. Veuillez réessayer. Inaccessible Unstakez + En raison d\'une limitations sur les %1$s, seuls les %2$d UTXO peuvent s\'intégrer dans une seule transaction. Ce qui signifie vous ne pouvez envoyer que %3$s ou moins. Réduisez le montant. semaine avec Oui @@ -258,6 +268,7 @@ Vérifiez votre connexion Internet ou passez à un réseau différent Conditions d\'utilisation Vous avez utilisé une carte d\'un autre portefeuille. Appuyez sur la carte associée à ce portefeuille + Pas assez de fonds pour la transaction. Veuillez recharger votre compte. Mes jetons Vous n\'avez pas encore ajouté de jetons. Ajoutez des jetons via Market pour effectuer une échange Ne peut pas être échangé contre %s @@ -337,6 +348,9 @@ Commentaires sur Tangem Impossible d\'effectuer une transaction Erreur de description de la pièce + Une erreur s\'est produite + Une erreur s\'est produite. Code:%s + Un mémo est requis Cette transaction Spécifiez la limite approuvée pour le jeton sélectionné Montant %s @@ -496,6 +510,20 @@ Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché Ajouter des jetons NFC n\'est pas disponible sur votre appareil + Les NFT envoyés à l\'adresse de votre portefeuille s\'afficheront ici. + Aucune collection pour le moment + Recevoir des NFT + Collections NFT + Certaines données peuvent ne pas se charger + Problèmes de chargement temporaires + %1$d NFT dans la collection %2$d + Appuyez ici pour recevoir le premier NFT + Collections NFT + Impossible de charger les données + Pour utiliser le réseau %1$s, vous devez payer la réserve de compte (%2$s%3$s), qui bloque et cache ce montant indéfiniment + Le compte de destination est inactif. Envoyez %s ou plus pour activer le compte. + Pour créer un compte, envoyez des fonds à cette adresse + Le compte destinataire n\'a pas de Trustline pour l\'actif envoyé qu\'il tente d\'envoyer. Vous devez définir un seul code d\'accès pour protéger tous vos appareils. Protéger Vous pourrez définir un code d\'accès individuel sur chaque carte plus tard @@ -705,6 +733,12 @@ ID de destination Entrez l\'adresse L\'adresse est la même que celle de votre portefeuille + Le montant minimal est de %s + La monnaie minimale est de %s + Frais de commissions non valides + Le solde minimal est de %s + Le compte cible n\'a pas été créé. Le montant à envoyer doit être de %s + frais de commissions ou plus + Erreur inconnue Mémo invalide. Il ne sera pas ajouté à la transaction. Mémo Vérifiez votre connexion réseau @@ -762,6 +796,12 @@ y compris des frais de réseau de %1$s La transaction a été signée avec succès et envoyée au nœud de blockchain. Le solde du portefeuille sera mis à jour après un certain temps %1$s est un actif du réseau Tron. Pour calculer les frais et effectuer une transaction, déposez du Tron (TRX) sur votre compte. + Le montant dépasse le solde + Une balise de destination (mémo) est requise pour terminer cette transaction pour l\'adresse spécifiée. + Étiquette de destination requise + Montant invalide + Les frais de commissions dépassent le solde + Le total dépasse le solde Transaction envoyée Scannez la carte/ bague que vous souhaitez configurer. Oublier le portefeuille @@ -783,6 +823,7 @@ %s profit estimatif Cote du marché Métriques + Selon les règles du réseau %1$s, les réclamations sont possibles à partir de %2$s. Les montants ci-dessous seront crédités sur votre compte lors du déblocage. Minimum requis Aucune récompense à réclamer Réclamation de récompense @@ -820,11 +861,16 @@ Solde de staking faible Un minimum de %1$s %2$s est requis pour le re-staking. Veuillez recharger votre solde. Pas assez de %s + Solde insuffisant pour le staking + Un minimum de 3 ADA est requis pour le re-staking. Veuillez recharger votre solde. + ADA insuffisants + Le montant minimum requis pour le staking doit être supérieur à 5 ADA. Veuillez recharger votre solde pour commencer à staking. L\'option de staking n\'est actuellement pas disponible en raison des conditions du réseau. Veuillez réessayer plus tard. Le staking dans le réseau %1$s avec un nouveau validateur transférera automatiquement tous les fonds précédemment stakés vers ce validateur Réinvestissez vos récompenses gagnées dans le montant que vous avez staké, augmentant ainsi vos gains potentiels. L\'option de restaker vous permet de déplacer vos fonds d\'un validateur à un autre sans avoir besoin de les déstaker. Vous êtes sur le point de staker l\'intégralité de votre solde. Nous vous recommandons de laisser un petit montant pour couvrir les frais de réseau pour unstaking ou la réclamation des récompenses. + Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille. Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s. Vos fonds seront disponibles à l\'utilisation après la période de déblocage de 21 jours. La récompense sera retirée en même temps que vos fonds de déblocage. Vos fonds seront disponibles pour utilisation après la période de désengagement %s. @@ -853,6 +899,7 @@ Récompenses Stake verrouillé Staker plus + Lorsque vous stakez %1$s, la totalité de votre solde %2$s est stakeée. Tout dépôt supplémentaire de %2$s sur votre portefeuille Tangem sera également staké automatiquement. Montant staké Vous stakez %1$s et recevrez %2$s Appuyez pour déverrouiller @@ -887,6 +934,8 @@ Découvrez Tangem Échangez, achetez des NFT, faites des prêts et des dépôts dans plus de 100 services décentralisés différents Compatible avec Web 3.0 + Une transaction entrante d\'au moins de %1$s est requise pour continuer + Fonds insuffisants Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange. Échangez plus de jetons à de meilleurs taux directement dans votre portefeuille. Nouveau fournisseur d\'échange disponible ! @@ -926,6 +975,7 @@ Vous n\'avez pas de fonds à envoyer. Renflouez votre compte pour pouvoir envoyer des fonds à partir de celui-ci. Die Daten wurden noch nicht geladen. Dies kann einige Sekunden dauern. Bitte versuchen Sie es später noch einmal. Le service d\'échange %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options. + Le solde affiché peut être obsolète en raison de la mise en cache. La vente de fonds sera disponible une fois que la ou les transactions en attente dans le réseau %s seront terminées L\'envoi de fonds sera disponible une fois la ou les transactions en attente dans le réseau %s terminées. L\'achat de %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options. @@ -972,6 +1022,7 @@ Tangem Twin Cette action est irréversible. Vous n\'aurez plus accès à l\'ancien portefeuille. Appuyez sur la carte jumelle avec le numéro %s et ne la retirez pas jusqu\'à la fin de l\'opération + Nous avons rencontré une erreur. Code d\'erreur : %s. Veuillez contacter notre équipe de support. Utilisez %s ou scannez une carte/bague pour avoir accès à votre portefeuille Échec de la connexion : Cette dApp utilise Wallet Connect version1.0, qui n\'est pas prise en charge. Veuillez vous assurer que la dApp prend en charge Wallet Connect version2.0 pour réussir la connexion. Restez à jour avec les dernières fonctionnalités et actualités @@ -986,6 +1037,24 @@ Renommer le portefeuille Tout déverrouiller Tout déverrouiller avec %s + + disponible pour %d jour + disponible pour %d jours + + Soldes et Limites + Êtes-vous sûr de vouloir quitter ? Vous pourrez reprendre plus tard là où vous vous étiez arrêté. + Cela ne prendra pas longtemps. Nous configurons votre compte. + Cela ne prendra pas longtemps. Nous terminons l\'activation. + Tout est en cours de préparation ! + Code PIN invalide : évitez les séquences ou les répétitions + Accéder le site Web + Continuons la configuration de votre compte. + Content de vous revoir ! + Suivez les étapes pour configurer votre compte. + Bienvenue ! + Déverrouiller + Scannez votre carte pour déverrouiller l\'accès + Déverrouillage nécessaire La blockchain n\'est pas accessible. Réessayez plus tard Scanner la carte ou la bague Ce portefeuille a déjà été activé auparavant.\nSi cela n\'a pas été fait par vous, veuillez contacter le support.\nTangem ne vend jamais de portefeuilles avec le code d\'accès pré-généré. @@ -1116,6 +1185,13 @@ Il s\'agit d\'une carte Testnet. Elle ne peut pas traiter les transactions et ne doit être utilisée qu\'à des fins de test et de développement. À des fins de test uniquement Le solde peut être obsolète. Rafraîchissez la page. + Connexions + Déconnecter tout + Texte sur la déconnexion de toutes les dApps + Déconnecter toutes les dApps + Nouvelle connexion + Connectez votre portefeuille à différentes dApps + Aucune séance Ignorer Vous avez une sauvegarde interrompue. Voulez-vous la reprendre ? Oui, reprendre @@ -1127,4 +1203,7 @@ Scanner la carte Utiliser %s ou scanner une carte pour accéder à l\'application Bon retour ! + Non, envoyer toute la somme + Réduire de %s XTZ + Pour ne pas payer un fraid de commissions élevé la prochaine fois que vous rechargez votre portefeuille, veuillez réduire le montant de %s XTZ diff --git a/core/res/src/main/res/values-it/strings-blockchain.xml b/core/res/src/main/res/values-it/strings-blockchain.xml deleted file mode 100644 index bedc136ed0..0000000000 --- a/core/res/src/main/res/values-it/strings-blockchain.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - Impossibile ottenere la commissione - Per creare un account, invia fondi a questo indirizzo - L\'importo minimo è di %s - L\'importo residuo è molto basso - Commissione non valida - Il conto totale non è stato creato L\'importo da inviare deve essere %s + commissione o maggiore - Errore sconosciuto - L\'importo supera il saldo - Importo non valido - La commissione supera il saldo - Il totale supera il saldo - No, invia l\'intero importo - Riduci di %s XTZ - Per evitare di pagare una commissione maggiore la prossima volta che ricarichi il tuo portafoglio, riduci l\'importo di %s XTZ - diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 9b25886a73..001ae1f84d 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -1,18 +1,25 @@ + Default + Legacy Questa carta non è progettata per funzionare con Tangem + L\'importo inviato e il cambio non può essere inferiore a 1 ADA Accetta Saldo: %s + Impossibile creare la transazione Non hai fornito l\'accesso alla tua videocamera, modifica le tue impostazioni sulla privacy Annulla Rimuovere Fatto Errore + Impossibile ottenere la commissione Costi della rete OK Mantieni le modifiche Invia + Impossibile inviare la transazione Con successo + Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. Codice di accesso Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto Mantenimento della carta @@ -27,22 +34,44 @@ Firmato Requisiti Termini del servizio + Not enough funds for the transaction. Please top up your account. + An error occurred + An error occurred. Code: %s. + Notifica richiesta Scansiona carta Avvicina per modificare il codice di accesso Avvicina per modificare la password Avvicina per scansionare Avvicina per firmare Avvicina la carta + You don\'t have enough Mana for this transaction. Please wait until the Mana is refilled. Your Mana balance is %1$s/%2$s + To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely + Destination account is not active. Send %s or more to activate the account. + Per creare un account, invia fondi a questo indirizzo + The destination account does not have a trustline for the asset being sent. Crea portafoglio Importo L\'indirizzo corrisponde all\'indirizzo del tuo portafoglio + L\'importo minimo è di %s + Il resto minimo è di %s + Commissione non valida + Minimum balance is %s + Il conto totale non è stato creato L\'importo da inviare deve essere %s + commissione o maggiore + Errore sconosciuto Memo Importo totale La transazione è stata firmata con successo e inviata al nodo blockchain. Il saldo del portafoglio verrà aggiornato dopo un po\' di tempo + L\'importo supera il saldo + Importo non valido + La commissione supera il saldo + Il totale supera il saldo Tangem Twin WalletConnect L\'indirizzo è stato copiato con successo Nessuna connessione a Internet Tangem Ok, ho capito! + No, invia l\'intero importo + Riduci di %s XTZ + Per evitare di pagare una commissione maggiore la prossima volta che ricarichi il tuo portafoglio, riduci l\'importo di %s XTZ diff --git a/core/res/src/main/res/values-ja/strings-blockchain.xml b/core/res/src/main/res/values-ja/strings-blockchain.xml deleted file mode 100644 index 5c5a94491d..0000000000 --- a/core/res/src/main/res/values-ja/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - デフォルト - レガシー - 手数料を取得できませんでした - %1$sの制限により、1つのトランザクションに収まるUTXOは%2$d個のみです。つまり、 %3$s以下しか送信できません。量を減らす必要があります。 - 取引に必要な資金が不足しています。アカウントに入金してください。 - エラーが発生しました。コード: %s 。 - %1$sネットワークを使用するには、アカウント準備金 ( %2$s %3$s ) を支払う必要があります。これにより、その金額はロックされ、無期限に非表示になります。 - 送信先アカウントが有効ではありません。%s 以上を送信してアカウントを有効にしてください。 - アカウントを作成するには、このアドレスに資金を送金してください - 最低額は%sです - 値が小さすぎます - 無効な手数料 - 最低残高は%sです - 対象アカウントは作成されていません。送信金額は、%s + 手数料またはそれ以上である必要があります。 - 不明なエラー - 金額が残高を超えています - 無効な金額 - 手数料が残高を超えています - 合計金額が残高を超えています - いいえ、すべて送信します - %s XTZを減らす - 次回ウォレットにチャージするときに手数料の増加を避けるには、金額を%s XTZ減らしてください。 - diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 9b67c45675..ec498830f5 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -21,6 +21,8 @@ カスタムトークンの追加 トークンの管理 このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。 + デフォルト + レガシー スキャン方法 サポートをリクエストする もう一度やり直してください @@ -82,6 +84,7 @@ Cardanoの取引要件 %1$s 取引を行うには、いくらかのADAを入金し、ネットワーク手数料と最小ADA (5ADAを推奨) をカバーする必要があります。 トークンを送るために必要なADAが不足しています。 + 送金額とお釣りは1ADA未満にすることはできません カルダノブロックチェーン上にトークンを持っているため、いくらかのADA残高を維持する必要があります。 ADAが不足しています。 受け入れる @@ -99,6 +102,7 @@ 残高 生体認証 生体認証 + 取引の構築に失敗しました 買う %1$sへ移動 カメラへのアクセスを許可していません。プライバシー設定を調整してください。 @@ -109,6 +113,8 @@ 報酬を受け取る 閉じる 確認 + Tangemサポートへ問い合わせる + Visaサポートへ問い合わせる 続ける コピー アドレスをコピー @@ -132,6 +138,7 @@ 移動する 取引履歴を調べる エクスプローラー + 手数料を取得できませんでした ネットワーク手数料は、取引の処理と確認のためにユーザーが支払う料金です。手数料の額は、ネットワークの混雑さ、取引のサイズ、実行の優先度によって左右されます。 %s 速い マーケット @@ -153,6 +160,7 @@ ネットワーク手数料 送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。 + NFT いいえ アドレスがありません @@ -175,10 +183,12 @@ 検索 トークンを検索 + すべて見る シードフレーズ アクションを選択 売る 送る + 取引の送信に失敗しました サーバーが利用できません。しばらくしてからもう一度お試しください。 共有 リンクを共有 @@ -202,6 +212,7 @@ エラーが発生しました。もう一度お試しください。 アクセスできません ステーキング解除 + %1$sの制限により、1つのトランザクションに収まるUTXOは%2$d個のみです。つまり、 %3$s以下しか送信できません。量を減らす必要があります。 はい @@ -258,6 +269,7 @@ インターネット接続を確認するか、別のネットワークに切り替えてください。 利用規約 別のウォレットのカードまたはリングを使用しました。このウォレットにリンクしているカードまたはリングをタップしてください。 + 取引に必要な資金が不足しています。アカウントに入金してください。 マイトークン まだトークンを追加していません。マーケットからトークンを追加して交換してください。 %sとの交換はできません @@ -295,6 +307,7 @@ 入金待ち 入金待ち... 返金済み + 処理中 あなたへ送金しています 送金中... 送金済み @@ -337,6 +350,9 @@ Tangemへのフィードバック 取引を送信できません コインの説明エラー + エラーが発生しました + エラーが発生しました。コード: %s 。 + メモが必要 取引 選択したトークンの承認制限を指定します 金額%s @@ -493,6 +509,8 @@ これをドラッグするか、検索窓をタップして、マーケットから直接トークンを追加します トークンを追加 お使いのデバイスではNFCが使用できません + NFTについて + NFTアセット アイテム @@ -502,10 +520,34 @@ NFTコレクション 一部のデータが読み込まれない場合があります 一時的な読み込みの問題 + 基本情報 + チェーン + コントラクトアドレス + 最終販売価格 + レアリティ・ラベル + レアリティ・ランク + トークンアドレス + トークンID + トークン標準 + 特徴 + 結果がありません。別のリクエストをお試しください。 + コレクションなし + 利用可能 + ネットワークを選択 + NFTを受け取る + このネットワークはまだ追加されていません。NFTの受け取りを開始するには、メイン画面に追加してください。 + ネットワークが追加されていません + 未追加 + NFTを送信する + 特徴 %1$dコレクションの%2$dNFT ここをタップして最初のNFTを受け取ります NFTコレクション データを読み込めません + %1$sネットワークを使用するには、アカウント準備金 ( %2$s %3$s ) を支払う必要があります。これにより、その金額はロックされ、無期限に非表示になります。 + 送信先アカウントが有効ではありません。%s 以上を送信してアカウントを有効にしてください。 + アカウントを作成するには、このアドレスに資金を送金してください + 送信先アカウントには、送金されるアセットのトラストラインがありません。 すべてのデバイスを保護するには、単一のアクセスコードを設定してください。 保護する 後で各カードおよびリングに個別のアクセスコードを設定できます。 @@ -635,6 +677,7 @@ カメラへのアクセスが拒否されました メモ不要 %3$sネットワーク上の%1$s ( %2$s ) + %2$sネットワーク上の%1$s 他の暗号資産を送信すると、取り返しのつかない損失が発生します。 このアドレスには%sのみを送金してください。他のトークンを送信すると、取り返しのつかない損失が発生します。 %2$sネットワークの%1$sのみを送信してください @@ -711,6 +754,12 @@ 宛先タグ アドレスを入力 アドレスはウォレットアドレスと同じです + 最低額は%sです + 最小の変更は%sです + 無効な手数料 + 最低残高は%sです + 対象アカウントは作成されていません。送信金額は、%s + 手数料またはそれ以上である必要があります。 + 不明なエラー 無効なメモです。取引には追加されません。 メモ ネットワーク接続を確認してください @@ -768,8 +817,12 @@ ネットワーク手数料%1$sを含む 取引は正常に署名され、ブロックチェーンノードに送信されました。ウォレットの残高はしばらくして更新されます。 %1$sはTronネットワークのアセットです。手数料を計算して取引を行うには、アカウントにTron(TRX)を入金する必要があります。 + 金額が残高を超えています 指定されたアドレスへの取引を完了するには、宛先タグ(メモ)が必要です。 宛先タグが必要です + 無効な金額 + 手数料が残高を超えています + 合計金額が残高を超えています 取引が送信されました 設定したいカードまたはリングをスキャンするために準備してください。 ウォレット削除 @@ -839,6 +892,11 @@ 再ステーキングを使うと、ステーキングを解除することなく、あるバリデータから別のバリデータに資金を移動できます。 残高のすべてをステーキングしようとしています。ステーキング解除や報酬請求にかかるネットワーク手数料をカバーするために、少額を残しておくことをお勧めします。 TONでステーキングを開始するには、まず任意の金額の送金を行ってください。これにより、ウォレットがアクティブになります。 + TONをステーキングまたは出金する場合、入金と出金の両方でステーキング額から0.2 TONの手数料が差し引かれます。 + この操作を続行するには、ネットワーク手数料に加えて0.2 TONが必要です。残高を補充してください。 + ステーキング手数料 + このアクションは、ネットワークのルールに従って、他のポジションをクローズするか、引き出しステータスに切り替えます。 + ポジション状況 資金をステーキングから引き出すには、ロックを解除してください。ロック解除には%sかかります。 資金は、21日間のロック解除期間後に使用可能になります。報酬は、ロック解除後の資金とともに引き出されます。 %sのステーキング解除期間後、資金はすぐ利用可能となります。 @@ -1015,12 +1073,14 @@ 長くはかかりません。アカウントを設定しています。 長くはかかりません。アクティベーションを完了しています。 準備完了です! + PINの認証に失敗しました。もう一度お試しいただくか、別のコードを使用してください。 無効な暗証番号:連続や繰り返しを避けてください ウェブサイトに移動 アカウントの設定を続けましょう。 お帰りなさい! 手順に従ってアカウントを設定してください。 ようこそ! + この取引に異議を唱える ロック解除 カードをスキャンしてアクセスロックを解除する ロック解除が必要 @@ -1153,13 +1213,41 @@ これはテストネットカードです。取引処理はできませんので、テストおよび開発目的でのみご利用ください。 テスト目的のみ 残高が古い可能性があります。ページを更新してください。 + 不明なドメイン + とにかく接続する + タイムアウトエラーが発生しました。しばらくしてからもう一度お試しください。 + WalletConnectを確立できませんでした + このドメインは検証できません。承認前にリクエスト内容をよく確認してください。 + ブラウザに戻り、WalletConnect経由で再度接続してください。 + WalletConnectセッションが接続解除されました + エラーコード: %s 。問題が解決しない場合は、お気軽にサポートまでお問い合わせください。 + 不明なエラーが発生しました + Tangemは現在%sで必要なネットワークをサポートしていません。 + サポートされていないネットワーク + Tangemは%sで必要なネットワークをサポートします。 + 検証済みドメイン + Tangemアプリで誤ったカードまたはリングが選択されました + 問題が起きています + 接続する + ネットワーク + ネットワーク + ウォレット + ウォレットの残高とアクティビティを表示する + 通知なしに取引に署名する + 取引の承認をリクエストする + できなくなります + したい + 接続リクエスト 接続 すべての接続を解除する すべてのdAppsの接続解除に関するテキスト すべてのdAppを接続解除する + このウォレットのプロフィールに%sネットワークを追加する + ウォレットに必要なネットワークはありません 新しい接続 ウォレットを別のdAppに接続する セッションなし + ウォレットコネクト 破棄 バックアップが中断されました。再開しますか? はい、再開します @@ -1171,4 +1259,7 @@ カードまたはリングをスキャン アプリにアクセスするには、 %sを使用するか、カードまたはリングをスキャンします お帰りなさい! + いいえ、すべて送信します + %s XTZを減らす + 次回ウォレットにチャージするときに手数料の増加を避けるには、金額を%s XTZ減らしてください。 diff --git a/core/res/src/main/res/values-ru/strings-blockchain.xml b/core/res/src/main/res/values-ru/strings-blockchain.xml deleted file mode 100644 index f589ed1f9f..0000000000 --- a/core/res/src/main/res/values-ru/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - По умолчанию - Устаревший - Не удалось получить комиссию - Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму. - Недостаточно средств для совершения транзакции. Пожалуйста, пополните свой аккаунт. - Произошла ошибка. Код: %s. - Чтобы использовать сеть %1$s, вы должны оплатить резерв аккаунта (%2$s %3$s), который блокируется и не используется в вашем балансе - Аккаунт получателя не активирован. Отправьте %s или более для активации аккаунта. - Для создания аккаунта отправьте средства на этот адрес - Минимальная сумма: %s - Сдача слишком мала - Неверная комиссия - Минимальный баланс: %s - Аккаунт получателя не создан. Сумма для отправки должна быть %s + комиссия или больше - Неизвестная ошибка - Сумма превышает остаток - Недопустимая сумма - Комиссия превышает остаток - Отправляемая сумма превышает остаток - Нет, отправить все - Уменьшить на %s XTZ - Чтобы не платить повышенную комиссию при следующем пополнении кошелька, уменьшите сумму на %s XTZ - diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index de090f81f6..401b2fea2a 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -21,6 +21,8 @@ Добавить токен Валюты Отправляйте только %1$s (%2$s) в сети %3$s на этот адрес. Использование другой сети может привести к утрате средств. + По умолчанию + Устаревший Как сканировать Обратиться в поддержку Попробовать снова @@ -85,6 +87,7 @@ Требования к транзакции Cardano Чтобы совершить транзакцию %1$s, внесите некоторую сумму ADA для покрытия сетевой комиссии и минимального значения ADA (рекомендуется 5 ADA) Недостаточно ADA для транзакции + Отправляемая сумма и сдача не могут быть меньше 1 ADA Вы должны поддерживать некоторое количество ADA, поскольку у вас на балансе есть токены в сети Cardano Недостаточно ADA Принять @@ -102,6 +105,7 @@ Баланс биометрическую аутентификацию биометрией + Не удалось создать транзакцию Купить Перейти на %1$s Вы не предоставили доступ к камере, пожалуйста, измените настройки конфиденциальности. @@ -141,6 +145,7 @@ Обозреватель Посмотреть историю транзакций Обозреватель + Не удалось получить комиссию Сетевые комиссии – это плата пользователя за обработку и подтверждение транзакций. Размер комиссии зависит от нагрузки на сеть, объема транзакции и приоритета исполнения. %s Быстро По рынку @@ -188,6 +193,7 @@ Выберите действие Продать Отправить + Не удалось отправить транзакцию Сервер недоступен, повторите попытку позднее Поделиться Поделиться ссылкой @@ -211,6 +217,7 @@ Произошла ошибка. Пожалуйста, попробуйте снова. Недоступно Завершить стейкинг + Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму. неделю с Да @@ -267,6 +274,7 @@ Проверьте подключение с интернетом или переключитесь на другую сеть Условия использования Вы использовали карту или кольцо от другого кошелька. Приложите карту или кольцо, связанную с этим кошельком. + Недостаточно средств для совершения транзакции. Пожалуйста, пополните свой аккаунт. Мои токены У вас нет добавленных токенов. Добавьте токены для обмена Недоступен для обмена с %s @@ -345,6 +353,9 @@ Обращение в поддержку Tangem Не могу отправить транзакцию Ошибка в описании монеты + Произошла ошибка + Произошла ошибка. Код: %s. + Требуется memo Транзакция Укажите лимит доступа к выбранному токену Количество %s @@ -510,6 +521,10 @@ Потяните вверх или коснитесь поисковой строки, чтобы добавить токены напрямую из рынка Добавить токены Функция NFC недоступна на вашем устройстве + Чтобы использовать сеть %1$s, вы должны оплатить резерв аккаунта (%2$s %3$s), который блокируется и не используется в вашем балансе + Аккаунт получателя не активирован. Отправьте %s или более для активации аккаунта. + Для создания аккаунта отправьте средства на этот адрес + Аккаунт получателя не содержит трастлайна для отправляемого актива. Установите единый код доступа для защиты всех ваших карт или колец Защита Установите индивидуальный код доступа для каждой карты или кольца позже. @@ -727,6 +742,12 @@ Код назначения Введите адрес Адрес совпадает с адресом кошелька + Минимальная сумма: %s + Минимальная сдача: %s + Неверная комиссия + Минимальный баланс: %s + Аккаунт получателя не создан. Сумма для отправки должна быть %s + комиссия или больше + Неизвестная ошибка Недопустимый Memo. Он не будет добавлен в транзакцию. Memo Проверьте своё интернет соединение @@ -784,7 +805,11 @@ включая комиссию сети в размере %1$s Транзакция успешно подписана и отправлена в блокчейн. Баланс будет обновлен через некоторое время %1$s — это монета в сети Tron. Чтобы рассчитать комиссию и совершить транзакцию, вам необходимо внести немного Tron (TRX) на свой адрес. + Сумма превышает остаток Для завершения транзакции на указанный адрес необходим тег назначения (мемо). + Недопустимая сумма + Комиссия превышает остаток + Отправляемая сумма превышает остаток Транзакция отправлена Подготовьтесь к сканированию кольца или карты, которую вы хотите настроить. Забыть кошелек @@ -853,6 +878,9 @@ Рестейк позволяет вам переместить средства из одного валидатора в другого без необходимости выхода из стейкинга. Вы собираетесь застейкать весь баланс, рекомендуем оставить небольшую сумму для оплаты комиссии сети при выходе из стейкинга или получении награды. Чтобы начать стейкинг в TON, сначала осуществите исходящую транзакцию на любую сумму — это активирует ваш кошелек. + В дополнение к сетевой комиссии может потребоваться до 0.2 TON для завершения всех этапов транзакции. Неиспользованная часть будет возвращена после выполнения. + Для выполнения операции требуется дополнительно 0.2 TON, помимо сетевой комиссии. Пожалуйста, пополните баланс. + Зарезервировано 0.2 TON Разблокируйте свои средства, чтобы вывести их из стейкинга. Разблокировка займёт %s. Ваши средства будут доступны для использования после 21-дневного периода отзыва. Награда будет выведена вместе с вашими выводими средствами. Ваши средства будут доступны после %s периода отзыва. @@ -1020,6 +1048,7 @@ Переименование кошелька Разблокировать все Разблокировать все с %s + ПИН не принят. Попробуйте ещё раз или введите другой код. Блокчейн недоступен. Попробуйте позже. Отсканируйте карту или кольцо Этот кошелек уже был активирован ранее.\nЕсли это сделали не вы, свяжитесь со службой поддержки.\nTangem никогда не продает кошелек вместе с предустановленным кодом доступа. @@ -1163,4 +1192,7 @@ Сканировать карту или кольцо Используйте %s или отсканируйте карту либо кольцо для входа в приложение C возвращением! + Нет, отправить все + Уменьшить на %s XTZ + Чтобы не платить повышенную комиссию при следующем пополнении кошелька, уменьшите сумму на %s XTZ diff --git a/core/res/src/main/res/values-uk-rUA/strings-blockchain.xml b/core/res/src/main/res/values-uk-rUA/strings-blockchain.xml deleted file mode 100644 index d2c3ed6cdf..0000000000 --- a/core/res/src/main/res/values-uk-rUA/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - За замовчуванням - Застарілий - Не вдалося отримати комісію - Через обмеження %1$s в одну транзакцію може поміститися тільки %2$d UTXO. Це означає, що ви можете відправити тільки %3$s або менше. Вам потрібно зменшити суму. - Недостатньо коштів для здійснення транзакції. Будь ласка, поповніть свій акаунт. - Виникла помилка. Код: %s. - Щоб користуватися мережею %1$s, ви маєте оплатити резерв акаунта (%2$s %3$s), який блокується і не використовується у вашому балансі - Обліковий запис одержувача не активовано. Надішліть %s або більше, щоб активувати обліковий запис. - Для створення акаунту надішліть кошти на цю адресу - Мінімальна сума: %s - Решта занадто мала - Невірна комісія - Мінімальний баланс: %s - Обліковий запис одержувача не створено. Сума для переказу повинна бути %s + комісія або більше - Невідома помилка - Сума перевищує залишок - Недопустима сума - Комісія перевищує залишок - Сума, що відправляється, перевищує залишок - Ні, відправити все - Зменшити на %s XTZ - Щоб не платити підвищену комісію при наступному поповненні гаманця, зменште суму на %s XTZ - 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 4b6a67ab8c..1a942a7a26 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -21,6 +21,8 @@ Додати токен Токени Надсилайте тільки %1$s (%2$s) в мережі %3$s на цю адресу. Використання іншої мережі може призвести до втрати коштів. + За замовчуванням + Застарілий Як сканувати Звернутися в підтримку Спробуйте ще раз @@ -85,6 +87,7 @@ Вимоги до транзакції Cardano Щоб здійснити транзакцію %1$s, внесіть певну суму ADA для покриття мережевої комісії та мінімальне значення ADA (рекомендовано 5 ADA) Недостатньо ADA для транзакції + Сума і решта, що відправляються, не можуть бути меншими за 1 ADA Ви повинні утримувати деяку кількість ADA, оскільки у вас на балансі є токени в мережі Cardano Недостатньо ADA Прийняти @@ -102,6 +105,7 @@ Баланс біометричну автентифікацію біометрії + Не вдалося створити транзакцію Купити Перейдіть до %1$s Ви не надали доступ до камери, будь ласка, змініть налаштування конфіденційності @@ -141,6 +145,7 @@ Оглядач Переглянути історію транзакцій Оглядач + Не вдалося отримати комісію Мережеві комісії – це збори, які користувачі сплачують за обробку та підтвердження транзакцій. На розмір комісії може впливати перевантаження мережі, розмір транзакції та пріоритет виконання. %s Швидко За ринком @@ -188,6 +193,7 @@ Оберіть дію Продати Надіслати + Не вдалося надіслати транзакцію Сервер недоступний, спробуйте пізніше Поширити Поділитися посиланням @@ -211,6 +217,7 @@ Виникла помилка. Будь ласка, спробуйте ще раз. Недоступно Скасувати стейкінг + Через обмеження %1$s в одну транзакцію може поміститися тільки %2$d UTXO. Це означає, що ви можете відправити тільки %3$s або менше. Вам потрібно зменшити суму. тиждень з Так @@ -267,6 +274,7 @@ Перевірте підключення до інтернету або змініть мережу Умови використання Ви використали картку або кільце від іншого гаманця. Прикладіть картку або кільце, пов\'язану з цим гаманцем. + Недостатньо коштів для здійснення транзакції. Будь ласка, поповніть свій акаунт. Мої токени У вас немає доданих токенів. Додайте токени для обміну Недоступний для обміну з %s @@ -346,6 +354,9 @@ Звернення в підтримку Tangem Не вдається відправити транзакцію Помилка в описі монети + Виникла помилка + Виникла помилка. Код: %s. + Вимагається memo Транзакція Вкажіть ліміт доступу для обраного токена Кількість %s @@ -511,6 +522,10 @@ Потягніть вгору або торкніться панелі пошуку, щоб додати токени безпосередньо з маркету Додати токени Функція NFC недоступна на вашому пристрої + Щоб користуватися мережею %1$s, ви маєте оплатити резерв акаунта (%2$s %3$s), який блокується і не використовується у вашому балансі + Обліковий запис одержувача не активовано. Надішліть %s або більше, щоб активувати обліковий запис. + Для створення акаунту надішліть кошти на цю адресу + Акаунт одержувача не містить трастлайну для активу, що надсилається. Налаштуйте єдиний код доступу для захисту всіх ваших карток або кілець Захист Встановіть індивідуальний код доступу для кожної картки або кільця пізніше. @@ -728,6 +743,12 @@ Тег призначення Введіть адресу Адреса збігається з адресою гаманця + Мінімальна сума: %s + Мінімальна решта: %s + Невірна комісія + Мінімальний баланс: %s + Обліковий запис одержувача не створено. Сума для переказу повинна бути %s + комісія або більше + Невідома помилка Недопустимий Memo. Він не буде доданий до транзакції. Memo Перевірте підключення до мережі @@ -785,8 +806,12 @@ включно з комісією мережі %1$s Транзакція успішно підписана і відправлена до блокчейну. Баланс гаманця буде оновлено через деякий час %1$s — це монета у мережі Tron. Щоб розрахувати комісію та здійснити транзакцію, вам необхідно внести певну кількість Tron (TRX) на свій рахунок. + Сума перевищує залишок Тег призначення (memo) потрібен для завершення цієї транзакції для вказаної адреси. Тег призначення обов\'язковий + Недопустима сума + Комісія перевищує залишок + Сума, що відправляється, перевищує залишок Трансакцію надіслано Підготуйтеся до сканування кільця або картки, яку ви хочете налаштувати. Забути гаманець @@ -1166,4 +1191,7 @@ Сканувати картку або кільце Використовуйте %s або відскануйте картку або кільце для входу у додаток З поверненням! + Ні, відправити все + Зменшити на %s XTZ + Щоб не платити підвищену комісію при наступному поповненні гаманця, зменште суму на %s XTZ diff --git a/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml b/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml deleted file mode 100644 index 7545da58e5..0000000000 --- a/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - 默認 - 遺留資產 - 獲取費用失敗 - 由於 %1$s 的限制,只有%2$d UTXO 可以放入單次交易中。這意味著您只能發送%3$s或更少數量。您需要減少數量。 - 沒有足夠的資金進行交易。請先入金 - - - 目標帳戶未激活。發送 %s 或更多以激活帳戶 - 要創建帳戶,請將資金發送到此地址 - 最小數量是 %s - 更動太小 - 無效費用 - 最低餘額為 %s - 未創建目標帳戶。發送金額應為 %s + 費用或更多 - 未知錯誤 - 金額超出餘額 - 無效金額 - 費用超出餘額 - 總金額超出餘額 - 不,全部發送 - 減少 %s XTZ - 為避免下次給錢包充值時支付增加的佣金,請將金額減少 %s XTZ - diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 814be67145..78849a3497 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -3,6 +3,8 @@ 添加自定義代幣 管理代幣 僅將 %1$s (%2$s) 從 %3$s 網絡發送到此地址。使用其他代幣和網絡可能會導致資金損失 + 默認 + 遺留資產 請求支持 再試一次 此功能不在展示模式中提供 @@ -36,6 +38,7 @@ 回復至原廠設置 安全模式 卡片設置 + 發送金額和找零不能少於1個ADA 接受 允許 注意 @@ -43,6 +46,7 @@ 餘額 生物識別 生物 + 建立交易失敗 購買 您尚未授予相機訪問權限,請更改您的隱私設置 删除 @@ -58,6 +62,7 @@ 允許 啟用 錯誤 + 獲取費用失敗 導入 進行中 網路費 @@ -73,6 +78,7 @@ 選擇 銷售 發送 + 發送交易失敗 伺服器不可用,請稍後在試 分享 簽署 @@ -85,6 +91,7 @@ 交易 我了解 無法觸達 + 由於 %1$s 的限制,只有%2$d UTXO 可以放入單次交易中。這意味著您只能發送%3$s或更少數量。您需要減少數量。 已複製代幣地址 支持的網路 @@ -121,6 +128,7 @@ 檢查您的網路連接或切換到其他網絡 服務條款 您使用了另一個錢包中的卡。點按與此錢包關聯的卡片 + 沒有足夠的資金進行交易。請先入金 以下信息是可選的。如果不想共享,可以將其刪除 告訴我們您缺少哪些功能,我們會盡力幫助您 請告訴我們你有什麼卡 @@ -131,6 +139,9 @@ 反饋 Tangem反饋 無法發送交易 + An error occurred + An error occurred. Code: %s. + 需要memo 數量 %s 要繼續,您需要允許 %1s 智能合約使用您的 %2s 賦予權限 @@ -142,10 +153,15 @@ 點擊簽名 點按卡片 您已更新生物識別登入,掃描您的卡進入 + You don\'t have enough Mana for this transaction. Please wait until the Mana is refilled. Your Mana balance is %1$s/%2$s 管理代幣 要訪問所有的網路您需要掃描卡片 掃描卡片 代幣 + To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely + 目標帳戶未激活。發送 %s 或更多以激活帳戶 + 要創建帳戶,請將資金發送到此地址 + 目標帳戶沒有用於發送資產的 trustline 保護 您可以稍後在每張卡上設置單獨的訪問密碼 個人化 @@ -255,10 +271,20 @@ 準備好您的卡! 數量 地址與錢包地址相同 + 最小數量是 %s + 最小變化是 %s + 無效費用 + 最低餘額為 %s + 未創建目標帳戶。發送金額應為 %s + 費用或更多 + 未知錯誤 Memo無效。 它不會被添加到交易中 Memo 最大值 交易已成功簽署並發送至區塊鏈節點。錢包餘額稍後更新 + 金額超出餘額 + 無效金額 + 費用超出餘額 + 總金額超出餘額 安全地存儲您的加密貨幣,同時將私鑰保存在您的卡中 創新式的硬體錢包 最多 3張實體卡片 到一個錢包 @@ -358,4 +384,7 @@ 掃描卡片 使用 %s 或掃描卡片以訪問該應用程序 歡迎回來! + 不,全部發送 + 減少 %s XTZ + 為避免下次給錢包充值時支付增加的佣金,請將金額減少 %s XTZ diff --git a/core/res/src/main/res/values/strings-blockchain.xml b/core/res/src/main/res/values/strings-blockchain.xml deleted file mode 100644 index ef4e2b2edb..0000000000 --- a/core/res/src/main/res/values/strings-blockchain.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Default - Legacy - Failed to get fee - Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. - Not enough funds for the transaction. Please top up your account. - An error occurred. Code: %s. - To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely - Destination account is not active. Send %s or more to activate the account. - To create account send funds to this address - Minimum amount is %s - Change is too small - Invalid Fee - Minimum balance is %s - Target account is not created. Amount to send should be %s + fee or more - Unknown error - Amount exceeds balance - Invalid amount - Fee exceeds balance - Total amount exceeds balance - 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 - diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index a23e1b705f..94ac2266d6 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -21,6 +21,8 @@ Add custom token Manage tokens Send only %1$s (%2$s) from %3$s network to this address. Using other tokens and networks may result in loss of funds. + Default + Legacy How to scan Request support Try again @@ -83,6 +85,7 @@ Cardano transaction requirements To make a %1$s transaction, you must deposit some ADA to cover the network fee and minimum ADA value (5 ADA recommended) Insufficient ADA for token transfer + Sent amount and change cannot be less than 1 ADA You must maintain some ADA because you have some tokens on the Cardano blockchain Not enough ADA Accept @@ -100,6 +103,7 @@ Balance biometric authentication biometrics + Failed to build transaction Buy Go to %1$s You have not given access to your camera, please adjust your privacy settings @@ -110,13 +114,14 @@ Claim rewards Close Confirm + Contact Tangem Support + Contact Visa Support Continue Copy Copy address Create %1$s (%2$s) Custom - NFT %d day %d days @@ -136,6 +141,7 @@ Explore Explore transaction history Explorer + Failed to get fee Network fees are charges users pay to process and confirm transactions. The fee amount can be affected by network congestion, transaction size, and execution priority. %s Fast Market @@ -157,6 +163,7 @@ Network fee Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level Next + NFT No No address Now @@ -184,6 +191,7 @@ Select action Sell Send + Failed to send transaction The server is not available, please try again later Share Share Link @@ -208,6 +216,7 @@ There was an error. Please try again. Unreachable Unstake + Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. week with Yes @@ -264,6 +273,7 @@ Check your internet connection or switch to a different network Terms of service You have used a card or ring from another wallet. Tap the card or ring associated with this wallet + Not enough funds for the transaction. Please top up your account. My tokens You haven\'t added any tokens yet. Add tokens via Market to swap Cannot be swapped for %s @@ -301,6 +311,7 @@ Awaiting deposit Awaiting deposit... Refunded + Refunding Sending to you Sending to you... Sent @@ -343,6 +354,9 @@ Tangem feedback Can\'t send a transaction Coin description error + An error occurred + An error occurred. Code: %s. + Requires memo Transaction Specify the approve limit for the selected token Amount %s @@ -502,6 +516,8 @@ Pull this up or tap the search bar to add tokens directly from the market Add tokens NFC is not available on your device + About NFT + NFT asset %d item %d items @@ -512,24 +528,33 @@ NFT collections Some data may not load Temporary loading problems + Base information + Chain + Contract Address + Last sale price + Rarity label + Rarity rank + Token Address + Token ID + Token Standard + Traits No results. Please try another request. + No collection + Available + Choose network Receive NFT - To My wallet + You haven\'t added this network yet. To start receiving NFTs, add it to the main screen! + Network not added + Not Added + Traits %1$d NFTs in %2$d collection Tap here to receive first NFT NFT collections Unable to load the data - Choose network - Last sale price - Rarity label - Rarity rank - Traits - Base information - Token Standard - Contract Address - Token ID - Token Address - Chain + To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely + Destination account is not active. Send %s or more to activate the account. + To create account send funds to this address + The destination account does not have a trustline for the asset being sent. Set up a single access code to protect all your devices. Protect Set an individual access code for each card or ring later. @@ -740,6 +765,12 @@ Destination Tag Enter address Address is the same as wallet address + Minimum amount is %s + Minimum change is %s + Invalid Fee + Minimum balance is %s + Target account is not created. Amount to send should be %s + fee or more + Unknown error Invalid Memo. It won\'t be added to the transaction. Memo Check your network connection @@ -797,8 +828,12 @@ including a network fee of %1$s Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while %1$s is an asset in the Tron network. To calculate the fee and make a transaction you need to deposit some Tron (TRX) in your account. + Amount exceeds balance A destination tag (memo) is required to complete this transaction for the specified address. Destination Tag Required + Invalid amount + Fee exceeds balance + Total amount exceeds balance Transaction sent Prepare to scan card or ring you want to set up. Forget wallet @@ -868,6 +903,11 @@ Restake lets you move your funds from one validator to another without the need to unstake You’re about to stake your entire balance. We recommend leaving a small amount to cover network fees for unstaking or claiming rewards. To start staking in TON, first make an outgoing transaction of any amount — this will activate your wallet. + In addition to the network fee, up to 0.2 TON is needed to complete all steps of the transaction. Unused amount will be returned after it\'s finished. + 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 + 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. Your funds will be available for use after the 21-day unbonding period. Reward will be withdrawn along with your unstaking funds. Your funds will be available for use after the %s unbonding period. @@ -1067,6 +1107,7 @@ Set up a 4-digit code. 
It will be used for payments. PIN code Create PIN Code + PIN was not accepted. Try again or use a different code. Invalid PIN: avoid sequences or repeats You\'re good to go! Prepare the Tangem card and tap to approve @@ -1098,6 +1139,7 @@ Transaction request Transaction status Type + Dispute this transaction Unlock Scan your card to unlock access Needed unlock @@ -1231,13 +1273,41 @@ This is a Testnet card. It cannot process transactions and should only be used for testing and development purposes. For testing purposes only Balance may be outdated. Refresh the page. + Unknown domain + Connect anyway + Timeout error. Please, try again later. + Failed to establish WalletConnect + This domain cannot be verified. Check the request carefully approving. + Go back to your browser and connect via WalletConnect again. + WalletConnect session was disconnected + Error code: %s. If the problem persists — feel free to contact our support. + We\'ve encountered unknown error + Tangem does not currently support a required network by %s. + Unsuported networks + Tangem support a required network by %s. + Verified domain + Wrong card or ring selected in Tangem App + We\'ve got some kind of problem + Connect + Network + Networks + Wallet + View your wallet balance and activity + Sign transactions without your notice + Request approval for transactions + Will not be able to + Would like to + Connection request Connections Disconnect all Text about discnected all dApps Disconect All dApps + Add the %s network to your profile for this wallet + The wallet has no required networks New connection Connect your wallet to a different dApps No sessions + Wallet connect Discard You have an interrupted backup. Do you want to resume? Yes, resume @@ -1249,4 +1319,7 @@ Scan card or ring Use %s or scan a card or ring to access the app Welcome back! + 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 diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt new file mode 100644 index 0000000000..5c1f568b02 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt @@ -0,0 +1,17 @@ +package com.tangem.core.ui.components.artwork + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Immutable +data class ArtworkUM( + val verifiedArtwork: ImmutableList? = null, + val defaultUrl: String, +) { + + constructor(bytes: ByteArray?, defaultUrl: String) : this( + verifiedArtwork = bytes?.toList()?.toImmutableList(), + defaultUrl = defaultUrl, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt new file mode 100644 index 0000000000..4f071c2b9d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt @@ -0,0 +1,485 @@ +package com.tangem.core.ui.components.atoms.text + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp + +/** + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +private const val READ_MORE_TAG = "read_more" +private const val READ_LESS_TAG = "read_less" + +/** + * Basic element that displays text with read more. + * + * @param text The text to be displayed. + * @param expanded whether this text is expanded or collapsed. + * @param modifier [Modifier] to apply to this layout node. + * @param onExpandRequested called when this text is clicked. If `null`, then this text will not be + * interactable, unless something else handles its input events and updates its state. + * @param contentPadding a padding around the text. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [readMoreOverflow] and TextAlign may have unexpected effects. + * @param readMoreText The read more text to be displayed in the collapsed state. + * @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if + * necessary. If the text exceeds the given number of lines, it will be truncated according to + * [readMoreOverflow]. If it is not null, then it must be greater than zero. + * @param readMoreOverflow How visual overflow should be handled in the collapsed state. + * @param readMoreStyle Style configuration for the read more text such as color, font, line height + * etc. + * @param readLessText The read less text to be displayed in the expanded state. + * @param readLessStyle Style configuration for the read less text such as color, font, line height + * etc. + * @param toggleArea A clickable area of text to toggle. + */ +@Composable +fun ReadMoreText( + text: String, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + ReadMoreTextInternal( + text = AnnotatedString(text), + expanded = expanded, + modifier = modifier, + onExpandRequested = onExpandRequested, + contentPadding = contentPadding, + style = style, + onTextLayout = onTextLayout, + softWrap = softWrap, + readMoreText = readMoreText, + readMoreMaxLines = readMoreMaxLines, + readMoreOverflow = readMoreOverflow, + readMoreStyle = readMoreStyle, + readLessText = readLessText, + readLessStyle = readLessStyle, + toggleArea = toggleArea, + ) +} + +/** + * Basic element that displays text with read more. + * + * @param text The text to be displayed. + * @param expanded whether this text is expanded or collapsed. + * @param modifier [Modifier] to apply to this layout node. + * @param onExpandRequested called when this text is clicked. If `null`, then this text will not be + * interactable, unless something else handles its input events and updates its state. + * @param contentPadding a padding around the text. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [readMoreOverflow] and TextAlign may have unexpected effects. + * @param readMoreText The read more text to be displayed in the collapsed state. + * @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if + * necessary. If the text exceeds the given number of lines, it will be truncated according to + * [readMoreOverflow]. If it is not null, then it must be greater than zero. + * @param readMoreOverflow How visual overflow should be handled in the collapsed state. + * @param readMoreStyle Style configuration for the read more text such as color, font, line height + * etc. + * @param readLessText The read less text to be displayed in the expanded state. + * @param readLessStyle Style configuration for the read less text such as color, font, line height + * etc. + * @param toggleArea A clickable area of text to toggle. + */ +@Composable +fun ReadMoreText( + text: AnnotatedString, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + ReadMoreTextInternal( + text = text, + expanded = expanded, + modifier = modifier, + onExpandRequested = onExpandRequested, + contentPadding = contentPadding, + style = style, + onTextLayout = onTextLayout, + softWrap = softWrap, + readMoreText = readMoreText, + readMoreMaxLines = readMoreMaxLines, + readMoreOverflow = readMoreOverflow, + readMoreStyle = readMoreStyle, + readLessText = readLessText, + readLessStyle = readLessStyle, + toggleArea = toggleArea, + ) +} + +@Suppress("LongMethod", "LongParameterList") +@Composable +private fun ReadMoreTextInternal( + text: AnnotatedString, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + require(readMoreMaxLines > 0) { "readMoreMaxLines should be greater than 0" } + + val overflowText: String = remember(readMoreOverflow) { + buildString { + when (readMoreOverflow) { + ReadMoreTextOverflow.Clip -> { + } + ReadMoreTextOverflow.Ellipsis -> { + append(Typography.ellipsis) + } + } + if (readMoreText.isNotEmpty()) { + append(Typography.nbsp) + } + } + } + val readMoreTextWithStyle: AnnotatedString = remember(readMoreText, readMoreStyle) { + buildAnnotatedString { + if (readMoreText.isNotEmpty()) { + withStyle(readMoreStyle) { + append(readMoreText.replace(' ', Typography.nbsp)) + } + } + } + } + val readLessTextWithStyle: AnnotatedString = remember(readLessText, readLessStyle) { + buildAnnotatedString { + if (readLessText.isNotEmpty()) { + withStyle(readLessStyle) { + append(readLessText) + } + } + } + } + + val textMeasurer = rememberTextMeasurer() + val state = remember { ReadMoreState() } + + val currentText = buildAnnotatedString { + if (expanded) { + append(text) + if (readLessTextWithStyle.isNotEmpty()) { + append(' ') + if (toggleArea == ToggleArea.More) { + withLink( + LinkAnnotation.Clickable(tag = READ_LESS_TAG) { + onExpandRequested?.invoke(false) + }, + ) { + append(readLessTextWithStyle) + } + } else { + append(readLessTextWithStyle) + } + } + } else { + val collapsedText = state.collapsedText + if (collapsedText.isNotEmpty()) { + append(collapsedText) + append(overflowText) + + if (toggleArea == ToggleArea.More) { + withLink( + LinkAnnotation.Clickable(tag = READ_MORE_TAG) { + onExpandRequested?.invoke(true) + }, + ) { + append(readMoreTextWithStyle) + } + } else { + append(readMoreTextWithStyle) + } + } else { + append(text) + } + } + } + val toggleableModifier = if (onExpandRequested != null && toggleArea == ToggleArea.All) { + Modifier.clickable( + enabled = state.isCollapsible, + onClick = { onExpandRequested(!expanded) }, + ) + } else { + Modifier + } + BoxWithConstraints( + modifier = modifier + .then(toggleableModifier) + .padding(contentPadding), + ) { + BasicText( + text = currentText, + modifier = Modifier, + style = style, + onTextLayout = onTextLayout, + overflow = TextOverflow.Ellipsis, + softWrap = softWrap, + maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines, + ) + + val constraints = Constraints(maxWidth = constraints.maxWidth) + LaunchedEffect( + textMeasurer, + constraints, + overflowText, + readMoreTextWithStyle, + style, + readMoreStyle, + text, + readMoreMaxLines, + softWrap, + ) { + state.applyCollapsedText( + textMeasurer = textMeasurer, + constraints = constraints, + overflowText = overflowText, + readMoreTextWithStyle = readMoreTextWithStyle, + style = style, + readMoreStyle = readMoreStyle, + text = text, + readMoreMaxLines = readMoreMaxLines, + softWrap = softWrap, + ) + } + } +} + +@Stable +private class ReadMoreState { + private var _collapsedText: AnnotatedString by mutableStateOf(AnnotatedString("")) + + var collapsedText: AnnotatedString + get() = _collapsedText + internal set(value) { + if (value != _collapsedText) { + _collapsedText = value + } + } + + val isCollapsible: Boolean + get() = collapsedText.isNotEmpty() + + @Suppress("LongParameterList") + fun applyCollapsedText( + textMeasurer: TextMeasurer, + constraints: Constraints, + overflowText: String, + readMoreTextWithStyle: AnnotatedString, + style: TextStyle, + readMoreStyle: SpanStyle, + text: AnnotatedString, + readMoreMaxLines: Int, + softWrap: Boolean, + ) { + val overflowTextWidth = if (overflowText.isNotEmpty()) { + textMeasurer.measure( + text = overflowText, + style = style, + ).size.width + } else { + 0 + } + val readMoreTextWidth = if (readMoreTextWithStyle.isNotEmpty()) { + textMeasurer.measure( + text = readMoreTextWithStyle, + style = style.merge(readMoreStyle), + ).size.width + } else { + 0 + } + val textLayout = textMeasurer.measure( + text = text, + style = style, + maxLines = readMoreMaxLines, + overflow = TextOverflow.Clip, + softWrap = softWrap, + constraints = constraints, + ) + + val clipTextCount = textLayout.getLineEnd(lineIndex = textLayout.lineCount - 1) + val isLineClipped = text.count() > clipTextCount + if (isLineClipped) { + val countUntilMaxLine = + textLayout.getLineEnd(readMoreMaxLines - 1, visibleEnd = true) + + val decorationWidth = overflowTextWidth + readMoreTextWidth + val replaceCount = text + .substringOf(textLayout, line = readMoreMaxLines) + .calculateReplaceCountToBeSingleLineWith( + maximumTextWidth = constraints.maxWidth - decorationWidth, + measureTextWidth = { subText -> + textMeasurer.measure( + text = subText, + style = style, + softWrap = softWrap, + ).size.width + }, + ) + collapsedText = text.subSequence(0, countUntilMaxLine - replaceCount) + } else { + collapsedText = AnnotatedString("") + } + } + + private fun AnnotatedString.substringOf(layout: TextLayoutResult, line: Int): AnnotatedString { + val lastLineStartIndex = layout.getLineStart(line - 1) + val lastLineEndIndex = layout.getLineEnd(line - 1, visibleEnd = true) + return subSequence(lastLineStartIndex, lastLineEndIndex) + } + + private inline fun AnnotatedString.calculateReplaceCountToBeSingleLineWith( + maximumTextWidth: Int, + measureTextWidth: (subText: AnnotatedString) -> Int, + ): Int { + var replacedTextWidth: Int + var replacedCount = -1 + do { + replacedCount++ + replacedTextWidth = measureTextWidth( + subSequence(0, this.length - replacedCount), + ) + } while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth) + + val lastVisibleChar: Char? = this.getOrNull(this.length - replacedCount - 1) + val firstOverflowChar: Char? = this.getOrNull(this.length - replacedCount) + if (lastVisibleChar?.isSurrogate() == true && firstOverflowChar?.isHighSurrogate() == false) { + val subText = subSequence(0, this.length - replacedCount) + if (subText.isNotEmpty()) { + return length - subText.indexOfLast { it.isHighSurrogate() } + } + } + return replacedCount + } +} + +@JvmInline +value class ToggleArea private constructor(internal val value: Int) { + + override fun toString(): String { + return when (this) { + All -> "All" + More -> "More" + else -> "Invalid" + } + } + + companion object { + /** + * All area of the text is clickable to toggle. + */ + @Stable + val All: ToggleArea = ToggleArea(1) + + /** + * 'More' and 'Less' area of the text is clickable to toggle. + */ + @Stable + val More: ToggleArea = ToggleArea(2) + } +} + +@JvmInline +value class ReadMoreTextOverflow private constructor(internal val value: Int) { + + override fun toString(): String { + return when (this) { + Clip -> "Clip" + Ellipsis -> "Ellipsis" + else -> "Invalid" + } + } + + companion object { + /** + * Clip the overflowing text to fix its container. + */ + @Stable + val Clip: ReadMoreTextOverflow = ReadMoreTextOverflow(1) + + /** + * Use an ellipsis to indicate that the text has overflowed. + */ + @Stable + val Ellipsis: ReadMoreTextOverflow = ReadMoreTextOverflow(2) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt index 03057d495d..f7020ce9e4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import com.tangem.core.ui.res.TangemTheme @@ -19,12 +20,13 @@ fun CurrencyIconTopBadge( alpha: Float, colorFilter: ColorFilter?, modifier: Modifier = Modifier, + background: Color = TangemTheme.colors.background.primary, ) { Box( modifier = modifier .size(TangemTheme.dimens.size18) .background( - color = TangemTheme.colors.background.primary, + color = background, shape = CircleShape, ), ) { 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 d69616e5d6..a302980668 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 @@ -41,6 +41,7 @@ fun SearchBar( state: SearchBarUM, modifier: Modifier = Modifier, colors: TextFieldColors = TangemSearchBarDefaults.defaultTextFieldColors, + enabled: Boolean = true, ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -57,6 +58,7 @@ fun SearchBar( state.onActiveChange(false) } }, + enabled = enabled, value = state.query, onValueChange = state.onQueryChange, interactionSource = interactionSource, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt index 7b8f2c9fff..a9020f35ae 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ChainRow.kt @@ -42,6 +42,7 @@ fun ChainRow(model: ChainRowUM, modifier: Modifier = Modifier, action: @Composab } else { null }, + isEnabled = model.enabled, accentMainText = true, accentSecondText = false, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/ChainRowUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/ChainRowUM.kt index c0c3764c5e..5b5f0ebd21 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/ChainRowUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/model/ChainRowUM.kt @@ -9,4 +9,5 @@ data class ChainRowUM( val type: String, val icon: CurrencyIconState, val showCustom: Boolean, + val enabled: Boolean = true, ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt index 7698a8067d..1d5172f597 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt @@ -95,6 +95,7 @@ data class TangemDimens internal constructor( val size142: Dp = 142.dp, val size158: Dp = 158.dp, val size164: Dp = 164.dp, + val size180: Dp = 180.dp, val size200: Dp = 200.dp, val size248: Dp = 248.dp, val size350: Dp = 350.dp, diff --git a/core/ui/src/main/res/drawable/ic_connect_new_24.xml b/core/ui/src/main/res/drawable/ic_connect_new_24.xml new file mode 100644 index 0000000000..523f1a7759 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_connect_new_24.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_network_new_24.xml b/core/ui/src/main/res/drawable/ic_network_new_24.xml new file mode 100644 index 0000000000..2e2f3e36ed --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_network_new_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_select_18_24.xml b/core/ui/src/main/res/drawable/ic_select_18_24.xml new file mode 100644 index 0000000000..5a131c0a6a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_select_18_24.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_wallet_new_24.xml b/core/ui/src/main/res/drawable/ic_wallet_new_24.xml new file mode 100644 index 0000000000..5add836f95 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_wallet_new_24.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_approvale2_20.xml b/core/ui/src/main/res/drawable/img_approvale2_20.xml new file mode 100644 index 0000000000..d5e74adaf9 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_approvale2_20.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_knight_shield_32.xml b/core/ui/src/main/res/drawable/img_knight_shield_32.xml new file mode 100644 index 0000000000..31dd7900c0 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_knight_shield_32.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt index 6e5a872f02..4676289125 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt @@ -24,7 +24,10 @@ class JobHolder { /** Cancel current [job] */ fun cancel() { job?.cancel() + job = null } + + fun isEmpty() = job == null } fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this) diff --git a/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt new file mode 100644 index 0000000000..0ab3e32398 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt @@ -0,0 +1,10 @@ +package com.tangem.utils.info + +interface AppInfoProvider { + val platform: String + val device: String + val osVersion: String + val language: String + val timezone: String + val appVersion: String +} \ No newline at end of file diff --git a/data/blockaid/.gitignore b/data/blockaid/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/blockaid/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/blockaid/build.gradle.kts b/data/blockaid/build.gradle.kts new file mode 100644 index 0000000000..344e76b061 --- /dev/null +++ b/data/blockaid/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.android.library) + id("configuration") +} + +android { + namespace = "com.tangem.data.blockaid" +} + +dependencies { + /* Project - Domain */ + implementation(projects.data.common) + implementation(projects.domain.blockaid) + implementation(projects.domain.blockaid.models) + + /* Project - Data */ + implementation(projects.core.datasource) + + /* Project - Core */ + implementation(projects.core.utils) + + /* DI */ + implementation(deps.hilt.core) + kapt(deps.hilt.kapt) + + /* Tangem libraries */ + implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) + + /* Other */ + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + /* Tests */ + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.turbine) + testImplementation(deps.test.truth) +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt new file mode 100644 index 0000000000..a36dea8518 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt @@ -0,0 +1,127 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.transaction.* +import com.domain.blockaid.models.transaction.simultation.ApprovedAmount +import com.domain.blockaid.models.transaction.simultation.SimulationData +import com.domain.blockaid.models.transaction.simultation.TokenInfo +import com.domain.blockaid.models.transaction.simultation.AmountInfo +import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.RpcData +import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.response.* + +private const val SUCCESS_STATUS = "Success" +private const val DOMAIN_CHECKED_STATUS = "hit" +private const val VALIDATION_SAFE_STATUS = "Benign" + +internal class BlockAidMapper { + + fun mapToDomain(from: DomainScanResponse): CheckDAppResult { + return when { + from.status != DOMAIN_CHECKED_STATUS -> CheckDAppResult.FAILED_TO_VERIFY + from.isMalicious == true -> CheckDAppResult.UNSAFE + else -> CheckDAppResult.SAFE + } + } + + fun mapToDomain(from: TransactionScanResponse): CheckTransactionResult { + return CheckTransactionResult( + validation = when { + from.validation.status != SUCCESS_STATUS -> ValidationResult.FAILED_TO_VALIDATE + from.validation.resultType == VALIDATION_SAFE_STATUS -> ValidationResult.SAFE + else -> ValidationResult.UNSAFE + }, + simulation = if (from.simulation.status != SUCCESS_STATUS) { + SimulationResult.FailedToSimulate + } else { + mapSimulationSuccessResult(from.simulation.accountSummary) + }, + ) + } + + fun mapToEvmRequest(from: TransactionData): EvmTransactionScanRequest { + return EvmTransactionScanRequest( + chain = from.chain, + accountAddress = from.accountAddress, + method = from.method, + data = RpcData( + method = from.method, + params = (from.params as TransactionParams.Evm).params, + ), + metadata = TransactionMetadata(from.domainUrl), + ) + } + + fun mapToSolanaRequest(from: TransactionData): SolanaTransactionScanRequest { + return SolanaTransactionScanRequest( + chain = from.chain, + accountAddress = from.accountAddress, + metadata = TransactionMetadata(from.domainUrl), + method = from.method, + transactions = (from.params as TransactionParams.Solana).transactions, + ) + } + + private fun mapSimulationSuccessResult(from: AccountSummaryResponse): SimulationResult { + return when { + from.assetsDiffs.isEmpty() && from.exposures.isNotEmpty() -> mapApproveTransaction(from.exposures) + from.assetsDiffs.isNotEmpty() && from.exposures.isEmpty() -> mapSendReceiveTransaction(from.assetsDiffs) + else -> SimulationResult.FailedToSimulate + } + } + + private fun mapApproveTransaction(exposures: List): SimulationResult { + val amounts = exposures.flatMap { exposure -> + val tokenInfo = TokenInfo( + chainId = exposure.asset.chainId, + logoUrl = exposure.asset.logoUrl, + symbol = exposure.asset.symbol, + ) + exposure.spenders.flatMap { (_, spender) -> + val isUnlimited = spender.isApprovedForAll == true + spender.exposure.mapNotNull { detail -> + ApprovedAmount( + approvedAmount = detail.value.toBigDecimalOrNull() ?: return@mapNotNull null, + isUnlimited = isUnlimited, + tokenInfo = tokenInfo, + ) + } + } + } + return if (amounts.isNotEmpty()) { + SimulationResult.Success(SimulationData.Approve(amounts)) + } else { + SimulationResult.FailedToSimulate + } + } + + private fun mapSendReceiveTransaction(assetDiffs: List): SimulationResult { + val sendInfo = arrayListOf() + val receiveInfo = arrayListOf() + + assetDiffs.forEach { diff -> + val token = TokenInfo( + chainId = diff.asset.chainId, + logoUrl = diff.asset.logoUrl, + symbol = diff.asset.symbol, + ) + diff.outTransfer.orEmpty().forEach { transfer -> + transfer.value.toBigDecimalOrNull()?.let { amount -> + sendInfo.add(AmountInfo(amount = amount, token = token)) + } + } + diff.inTransfer.orEmpty().forEach { transfer -> + transfer.value.toBigDecimalOrNull()?.let { amount -> + receiveInfo.add(AmountInfo(amount = amount, token = token)) + } + } + } + + return if (sendInfo.isNotEmpty() || receiveInfo.isNotEmpty()) { + SimulationResult.Success(SimulationData.SendAndReceive(send = sendInfo, receive = receiveInfo)) + } else { + SimulationResult.FailedToSimulate + } + } +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidRepository.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidRepository.kt new file mode 100644 index 0000000000..57e8db2559 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidRepository.kt @@ -0,0 +1,13 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData + +interface BlockAidRepository { + + suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult + + suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt new file mode 100644 index 0000000000..8659672ffe --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt @@ -0,0 +1,39 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData +import com.domain.blockaid.models.transaction.TransactionParams +import com.tangem.datasource.api.common.blockaid.BlockAidApi +import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class DefaultBlockAidRepository( + private val api: BlockAidApi, + private val dispatcherProvider: CoroutineDispatcherProvider, + private val mapper: BlockAidMapper, +) : BlockAidRepository { + + override suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult { + val response = withContext(dispatcherProvider.io) { + api.scanDomain(DomainScanRequest(data.url)) + } + return mapper.mapToDomain(response) + } + + override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult { + val response = withContext(dispatcherProvider.io) { + when (data.params) { + is TransactionParams.Evm -> { + api.scanJsonRpc(mapper.mapToEvmRequest(data)) + } + is TransactionParams.Solana -> { + api.scanSolanaMessage(mapper.mapToSolanaRequest(data)) + } + } + } + return mapper.mapToDomain(response) + } +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidVerifier.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidVerifier.kt new file mode 100644 index 0000000000..95d5070701 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidVerifier.kt @@ -0,0 +1,31 @@ +package com.tangem.data.blockaid + +import arrow.core.Either +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData +import com.tangem.domain.blockaid.BlockAidVerifier +import javax.inject.Inject + +/** + * Verifies the safety of DApps and WalletConnect transactions + */ +class DefaultBlockAidVerifier @Inject constructor( + private val repository: BlockAidRepository, +) : BlockAidVerifier { + + /** + * Checks if a DApp is safe to use + */ + override suspend fun verifyDApp(data: DAppData): Either { + return Either.catch { repository.verifyDAppDomain(data) } + } + + /** + * Checks the safety of a WalletConnect transaction and provides a simulation result + */ + override suspend fun verifyTransaction(data: TransactionData): Either { + return Either.catch { repository.verifyTransaction(data) } + } +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataInternalModule.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataInternalModule.kt new file mode 100644 index 0000000000..4f4ff71605 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataInternalModule.kt @@ -0,0 +1,27 @@ +package com.tangem.data.blockaid.di + +import com.tangem.data.blockaid.BlockAidMapper +import com.tangem.data.blockaid.BlockAidRepository +import com.tangem.data.blockaid.DefaultBlockAidRepository +import com.tangem.datasource.api.common.blockaid.BlockAidApi +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 BlockAidDataInternalModule { + + @Provides + @Singleton + fun provideRepository(api: BlockAidApi, dispatcherProvider: CoroutineDispatcherProvider): BlockAidRepository { + return DefaultBlockAidRepository( + api = api, + dispatcherProvider = dispatcherProvider, + mapper = BlockAidMapper(), + ) + } +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataModule.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataModule.kt new file mode 100644 index 0000000000..1efc366513 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.blockaid.di + +import com.tangem.data.blockaid.DefaultBlockAidVerifier +import com.tangem.domain.blockaid.BlockAidVerifier +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface BlockAidDataModule { + + @Binds + @Singleton + fun bindVerifier(verifier: DefaultBlockAidVerifier): BlockAidVerifier +} \ No newline at end of file diff --git a/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt new file mode 100644 index 0000000000..faaba4926c --- /dev/null +++ b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt @@ -0,0 +1,155 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.transaction.SimulationResult +import com.domain.blockaid.models.transaction.ValidationResult +import com.domain.blockaid.models.transaction.simultation.SimulationData +import com.google.common.truth.Truth +import com.tangem.datasource.api.common.blockaid.models.response.* +import org.junit.Test +import java.math.BigDecimal + +class BlockAidMapperTest { + + private val mapper = BlockAidMapper() + + @Test + fun `when status hit and is malicious false then map to domain returns safe`() { + val response = DomainScanResponse(status = "hit", isMalicious = false) + val result = mapper.mapToDomain(response) + Truth.assertThat(result).isEqualTo(CheckDAppResult.SAFE) + } + + @Test + fun `when status hit and is malicious true then map to domain returns unsafe`() { + val response = DomainScanResponse(status = "hit", isMalicious = true) + val result = mapper.mapToDomain(response) + Truth.assertThat(result).isEqualTo(CheckDAppResult.UNSAFE) + } + + @Test + fun `when status not hit then map to domain returns failed to verify`() { + val response = DomainScanResponse(status = "miss", isMalicious = false) + val result = mapper.mapToDomain(response) + Truth.assertThat(result).isEqualTo(CheckDAppResult.FAILED_TO_VERIFY) + } + + @Test + fun `when response benign validation then returns safe validation`() { + val spenderDetails = SpenderDetails( + isApprovedForAll = true, + exposure = listOf(ExposureDetail(value = "1000.0", rawValue = "0x123")), + ) + val exposure = Exposure( + asset = Asset(chainId = 1, logoUrl = "logo", symbol = "PEPE"), + spenders = mapOf("spender" to spenderDetails), + ) + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(assetsDiffs = emptyList(), exposures = listOf(exposure)), + ), + ) + + val result = mapper.mapToDomain(response) + Truth.assertThat(result.validation).isEqualTo(ValidationResult.SAFE) + + val simulation = result.simulation as? SimulationResult.Success + Truth.assertThat(simulation).isNotNull() + + val approve = simulation?.data as? SimulationData.Approve + Truth.assertThat(approve).isNotNull() + Truth.assertThat(approve?.approvedAmounts?.size).isEqualTo(1) + Truth.assertThat(approve?.approvedAmounts?.first()?.approvedAmount).isEqualTo(BigDecimal("1000.0")) + Truth.assertThat(approve?.approvedAmounts?.first()?.isUnlimited).isTrue() + } + + @Test + fun `when response benign validation and success simulation then returns send receive result`() { + val assetDiff = AssetDiff( + assetType = "ERC20", + asset = Asset(chainId = 1, logoUrl = "logo", symbol = "ETH"), + inTransfer = listOf(Transfer(value = "2.0", rawValue = "0x1")), + outTransfer = listOf(Transfer(value = "1.5", rawValue = "0x2")), + ) + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(exposures = emptyList(), assetsDiffs = listOf(assetDiff)), + ), + ) + + val result = mapper.mapToDomain(response) + Truth.assertThat(result.validation).isEqualTo(ValidationResult.SAFE) + + val simulation = result.simulation as? SimulationResult.Success + Truth.assertThat(simulation).isNotNull() + + val data = simulation?.data as? SimulationData.SendAndReceive + Truth.assertThat(data).isNotNull() + Truth.assertThat(data?.send?.first()?.amount).isEqualTo(BigDecimal("1.5")) + Truth.assertThat(data?.receive?.first()?.amount).isEqualTo(BigDecimal("2.0")) + } + + @Test + fun `when response error validation rhen returns failed to validate`() { + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Error", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(emptyList(), emptyList()), + ), + ) + + val result = mapper.mapToDomain(response) + Truth.assertThat(result.validation).isEqualTo(ValidationResult.FAILED_TO_VALIDATE) + Truth.assertThat(result.simulation is SimulationResult.FailedToSimulate).isTrue() + } + + @Test + fun `when response not benign then returns validation unsafe`() { + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Phishing"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(emptyList(), emptyList()), + ), + ) + + val result = mapper.mapToDomain(response) + Truth.assertThat(result.validation).isEqualTo(ValidationResult.UNSAFE) + } + + @Test + fun `when response simulation not success then returns simulation failed ro simulate`() { + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Error", + accountSummary = AccountSummaryResponse(emptyList(), emptyList()), + ), + ) + + val result = mapper.mapToDomain(response) + Truth.assertThat(result.simulation is SimulationResult.FailedToSimulate).isTrue() + } + + @Test + fun `when response simulation is empty then returns failed to simulate`() { + val txResponse = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse( + assetsDiffs = emptyList(), + exposures = emptyList(), + ), + ), + ) + + val result = mapper.mapToDomain(txResponse) + Truth.assertThat(result.simulation is SimulationResult.FailedToSimulate).isTrue() + } +} \ No newline at end of file diff --git a/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepositoryTest.kt b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepositoryTest.kt new file mode 100644 index 0000000000..036ed43815 --- /dev/null +++ b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepositoryTest.kt @@ -0,0 +1,108 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData +import com.domain.blockaid.models.transaction.TransactionParams +import com.google.common.truth.Truth +import com.tangem.datasource.api.common.blockaid.BlockAidApi +import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse +import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class DefaultBlockAidRepositoryTest { + + @MockK + private lateinit var api: BlockAidApi + + @MockK + private lateinit var mapper: BlockAidMapper + + private lateinit var repository: DefaultBlockAidRepository + + private val testDispatcherProvider = TestingCoroutineDispatcherProvider() + + @Before + fun setup() { + MockKAnnotations.init(this) + repository = DefaultBlockAidRepository(api, testDispatcherProvider, mapper) + } + + @Test + fun `when verify app domain then calls api and maps result`() = runTest { + val url = "https://example.com" + val domainData = DAppData(url) + val domainResponse = DomainScanResponse(status = "hit", isMalicious = false) + val expectedResult = CheckDAppResult.SAFE + + coEvery { api.scanDomain(DomainScanRequest(url)) } returns domainResponse + every { mapper.mapToDomain(domainResponse) } returns expectedResult + + val result = repository.verifyDAppDomain(domainData) + + Truth.assertThat(result).isEqualTo(expectedResult) + coVerify { api.scanDomain(DomainScanRequest(url)) } + verify { mapper.mapToDomain(domainResponse) } + } + + @Test + fun `when verify evm transaction then calls scan json rpc and maps result`() = runTest { + val data = TransactionData( + chain = "ethereum", + accountAddress = "0xabc", + domainUrl = "https://uniswap.org", + method = "eth_sendTransaction", + params = TransactionParams.Evm(params = "some-params"), + ) + + val request = mockk() + val response = mockk() + val expectedResult = mockk() + + every { mapper.mapToEvmRequest(data) } returns request + coEvery { api.scanJsonRpc(request) } returns response + every { mapper.mapToDomain(response) } returns expectedResult + + val result = repository.verifyTransaction(data) + + Truth.assertThat(result).isEqualTo(expectedResult) + coVerify { api.scanJsonRpc(request) } + verify { mapper.mapToEvmRequest(data) } + verify { mapper.mapToDomain(response) } + } + + @Test + fun `when verify solana transaction then calls scan solana message and maps result`() = runTest { + val data = TransactionData( + chain = "mainnet", + accountAddress = "/Rd2TLl...", + domainUrl = "https://example.com", + method = "signTransaction", + params = TransactionParams.Solana(transactions = listOf("TX_PAYLOAD_BASE64")), + ) + + val request = mockk() + val response = mockk() + val expectedResult = mockk() + + every { mapper.mapToSolanaRequest(data) } returns request + coEvery { api.scanSolanaMessage(request) } returns response + every { mapper.mapToDomain(response) } returns expectedResult + + val result = repository.verifyTransaction(data) + + Truth.assertThat(result).isEqualTo(expectedResult) + coVerify { api.scanSolanaMessage(request) } + verify { mapper.mapToSolanaRequest(data) } + verify { mapper.mapToDomain(response) } + } +} \ 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 index c1855597a2..7fc333d67d 100644 --- 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 @@ -1,6 +1,7 @@ package com.tangem.data.feedback.converters import com.tangem.domain.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.common.util.getBackupCardsCount import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.models.scan.CardDTO @@ -29,6 +30,7 @@ internal object CardInfoConverter : Converter { }, isImported = value.card.wallets.any(CardDTO.Wallet::isImported), isStart2Coin = value.card.isStart2Coin, + isVisa = value.card.isVisa, ) } } diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt index 274e82eae5..6dc441b61c 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt @@ -65,7 +65,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -95,7 +95,7 @@ internal class DefaultMultiNetworkStatusProducerTest { networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -112,7 +112,7 @@ internal class DefaultMultiNetworkStatusProducerTest { networksStatusesFlow.emit(updatedStatuses.map(NetworkStatus::toSimple).toSet()) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -143,7 +143,7 @@ internal class DefaultMultiNetworkStatusProducerTest { networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -155,7 +155,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // second emit networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -191,7 +191,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -202,7 +202,7 @@ internal class DefaultMultiNetworkStatusProducerTest { innerFlow.emit(value = true) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -222,7 +222,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) verify(inverse = true) { userWalletsStore.getSyncOrNull(params.userWalletId) } @@ -248,7 +248,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. diff --git a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt index 98894ea109..09a2126524 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt @@ -53,7 +53,7 @@ internal class DefaultSingleNetworkStatusProducerTest { verify { multiNetworkStatusSupplier(multiParams) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(1) Truth.assertThat(values).isEqualTo(listOf(status)) @@ -74,7 +74,7 @@ internal class DefaultSingleNetworkStatusProducerTest { val status = MockNetworkStatusFactory.createMissedDerivation(params.network) expected.emit(value = setOf(status)) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) Truth.assertThat(values1).isEqualTo(listOf(status)) @@ -83,7 +83,7 @@ internal class DefaultSingleNetworkStatusProducerTest { val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null)) expected.emit(value = setOf(updatedStatus)) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) Truth.assertThat(values2.size).isEqualTo(2) Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus)) @@ -104,7 +104,7 @@ internal class DefaultSingleNetworkStatusProducerTest { val status = MockNetworkStatusFactory.createMissedDerivation(params.network) expected.emit(value = setOf(status)) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) Truth.assertThat(values1).isEqualTo(listOf(status)) @@ -112,7 +112,7 @@ internal class DefaultSingleNetworkStatusProducerTest { // second emit expected.emit(value = setOf(status)) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) Truth.assertThat(values2.size).isEqualTo(1) Truth.assertThat(values2).isEqualTo(listOf(status)) @@ -140,7 +140,7 @@ internal class DefaultSingleNetworkStatusProducerTest { verify { multiNetworkStatusSupplier(multiParams) } - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network) @@ -148,7 +148,7 @@ internal class DefaultSingleNetworkStatusProducerTest { innerFlow.emit(value = true) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) Truth.assertThat(values2.size).isEqualTo(1) Truth.assertThat(values2).isEqualTo(listOf(status)) } @@ -166,7 +166,7 @@ internal class DefaultSingleNetworkStatusProducerTest { verify { multiNetworkStatusSupplier(multiParams) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(0) } diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt index df73b079bb..522fcbeacc 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt @@ -30,7 +30,7 @@ internal class NetworksStatusesStoreGetMethodTest { fun `test get if runtime store is empty`() = runTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values).isEqualTo(emptyList>()) } @@ -41,7 +41,7 @@ internal class NetworksStatusesStoreGetMethodTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values).isEqualTo(emptyList>()) } @@ -54,7 +54,7 @@ internal class NetworksStatusesStoreGetMethodTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(1) Truth.assertThat(values).isEqualTo(listOf(emptySet())) @@ -70,7 +70,7 @@ internal class NetworksStatusesStoreGetMethodTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(1) Truth.assertThat(values).isEqualTo(listOf(setOf(status.toSimple()))) diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt index 08314ee68e..94d819b827 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt @@ -31,7 +31,7 @@ internal class NetworksStatusesStoreInitializationTest { DefaultNetworksStatusesStoreV2( runtimeStore = runtimeStore, - persistenceDataStore = persistenceStore, // local mock + persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), ) diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index 197249ec85..aa6b40e845 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -2,7 +2,9 @@ package com.tangem.data.nft import arrow.core.Either import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.common.currency.getNetwork import com.tangem.datasource.local.nft.NFTPersistenceStore import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory import com.tangem.datasource.local.nft.NFTRuntimeStore @@ -10,7 +12,9 @@ import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter +import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.models.StatusSource +import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.nft.models.NFTCollections import com.tangem.domain.nft.models.NFTSalePrice @@ -36,6 +40,8 @@ internal class DefaultNFTRepository @Inject constructor( private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory, private val walletManagersFacade: WalletManagersFacade, private val dispatchers: CoroutineDispatcherProvider, + private val excludedBlockchains: ExcludedBlockchains, + private val userWalletsStore: UserWalletsStore, ) : NFTRepository { private val networkJobs = ConcurrentHashMap() @@ -63,38 +69,11 @@ internal class DefaultNFTRepository @Inject constructor( flowOf(NFTCollections.empty(network)) } - override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List) = coroutineScope { - networks.mapNotNull { network -> - if (network.canHandleNFTs()) { - launch(dispatchers.io) { - Either.catch { - expireCollections(userWalletId, network) + override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List) = + refreshCollectionsInternal(userWalletId, networks, refreshAssets = false) - val collections = walletManagersFacade.getNFTCollections(userWalletId, network) - val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network) - - saveCollectionsInRuntime( - userWalletId = userWalletId, - network = network, - collections = mergedCollections, - ) - saveCollectionsInPersistence( - userWalletId = userWalletId, - network = network, - collections = mergedCollections, - ) - }.onLeft { - saveFailedStateInRuntime( - userWalletId = userWalletId, - network = network, - error = it, - ) - } - }.saveIn(getNetworkJobHolder(network)) - } else { - null - } - }.joinAll() + override suspend fun refreshAll(userWalletId: UserWalletId, networks: List) { + refreshCollectionsInternal(userWalletId, networks, refreshAssets = true) } override suspend fun refreshAssets( @@ -117,7 +96,7 @@ internal class DefaultNFTRepository @Inject constructor( assets.forEach { val assetId = NFTSdkAssetIdentifierConverter.convert(it.identifier) val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId) - if (price is NFTSalePrice.Error) { + if (price is NFTSalePrice.Empty || price is NFTSalePrice.Error) { refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier) } } @@ -155,6 +134,75 @@ internal class DefaultNFTRepository @Inject constructor( override suspend fun isNFTSupported(network: Network): Boolean = network.canHandleNFTs() + override suspend fun getNFTSupportedNetworks(userWalletId: UserWalletId): List { + val userWallet = userWalletsStore.getSyncStrict(userWalletId) + return Blockchain + .entries + .filter { it.canHandleNFTs() && !it.isTestnet() } + .mapNotNull { + getNetwork( + blockchain = it, + extraDerivationPath = null, + scanResponse = userWallet.scanResponse, + excludedBlockchains = excludedBlockchains, + ) + } + } + + override suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? = + walletManagersFacade.getNFTExploreUrl( + network = network, + assetIdentifier = NFTSdkAssetIdentifierConverter.convertBack(assetIdentifier), + ) + + private suspend fun refreshCollectionsInternal( + userWalletId: UserWalletId, + networks: List, + refreshAssets: Boolean, + ) = coroutineScope { + networks.mapNotNull { network -> + if (network.canHandleNFTs()) { + launch(dispatchers.io) { + Either.catch { + expireCollections(userWalletId, network) + + val collections = walletManagersFacade.getNFTCollections(userWalletId, network) + val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network) + + saveCollectionsInRuntime( + userWalletId = userWalletId, + network = network, + collections = mergedCollections, + ) + saveCollectionsInPersistence( + userWalletId = userWalletId, + network = network, + collections = mergedCollections, + ) + + if (refreshAssets) { + mergedCollections.forEach { collection -> + refreshAssets( + userWalletId = userWalletId, + network = network, + collectionId = NFTSdkCollectionIdentifierConverter.convert(collection.identifier), + ) + } + } + }.onLeft { + saveFailedStateInRuntime( + userWalletId = userWalletId, + network = network, + error = it, + ) + } + }.saveIn(getNetworkJobHolder(network)) + } else { + null + } + }.joinAll() + } + private suspend fun refreshSalePrice( userWalletId: UserWalletId, network: Network, diff --git a/data/notifications/.gitignore b/data/notifications/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/data/notifications/.gitignore @@ -0,0 +1 @@ +/build diff --git a/data/notifications/build.gradle.kts b/data/notifications/build.gradle.kts new file mode 100644 index 0000000000..b3c107f390 --- /dev/null +++ b/data/notifications/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.notifications" +} + +dependencies { + + // region AndroidX libraries + implementation(deps.androidx.datastore) + implementation(deps.kotlin.coroutines) + // endregion + + // region DI + implementation(deps.hilt.core) + kapt(deps.hilt.kapt) + // endregion + + // region Arrow + implementation(deps.arrow.core) + // endregion + + // region Core modules + implementation(projects.core.datasource) + implementation(projects.core.utils) + // endregion + + // region Domain modules + implementation(projects.domain.notifications.models) + implementation(projects.domain.notifications) + // endregion + + // region tests + testImplementation(deps.test.junit) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) + testImplementation(deps.moshi) + testImplementation(deps.moshi.kotlin) + // endregion +} \ No newline at end of file diff --git a/data/notifications/src/main/java/com/tangem/data/notifications/DefaultNotificationsRepository.kt b/data/notifications/src/main/java/com/tangem/data/notifications/DefaultNotificationsRepository.kt new file mode 100644 index 0000000000..8648ace195 --- /dev/null +++ b/data/notifications/src/main/java/com/tangem/data/notifications/DefaultNotificationsRepository.kt @@ -0,0 +1,96 @@ +package com.tangem.data.notifications + +import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.NotificationApplicationCreateBody +import com.tangem.datasource.api.tangemTech.models.WalletBody +import com.tangem.datasource.api.tangemTech.models.WalletIdBody +import com.tangem.utils.info.AppInfoProvider +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.* +import com.tangem.domain.notifications.repository.NotificationsRepository +import com.tangem.domain.notifications.models.NotificationsEligibleNetwork +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class DefaultNotificationsRepository @Inject constructor( + private val tangemTechApi: TangemTechApi, + private val appInfoProvider: AppInfoProvider, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : NotificationsRepository { + + override suspend fun createApplicationId(pushToken: String?): String = withContext(dispatchers.io) { + tangemTechApi.createApplicationId( + NotificationApplicationCreateBody( + platform = appInfoProvider.platform, + device = appInfoProvider.device, + systemVersion = appInfoProvider.osVersion, + language = appInfoProvider.language, + timezone = appInfoProvider.timezone, + pushToken = pushToken, + ), + ).getOrThrow().appId + } + + override suspend fun saveApplicationId(appId: String) { + appPreferencesStore.store(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY, appId) + } + + override suspend fun getApplicationId(): String? { + return appPreferencesStore.getSyncOrNull(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY) + } + + override suspend fun setNotificationsEnabledForWallet(walletId: String, enabled: Boolean) = + withContext(dispatchers.io) { + tangemTechApi.setNotificationsEnabled( + walletId = walletId, + body = WalletBody(notifyStatus = enabled), + ).getOrThrow() + } + + override suspend fun associateApplicationIdWithWallets(appId: String, wallets: List) = + withContext(dispatchers.io) { + tangemTechApi.associateApplicationIdWithWallets( + applicationId = appId, + body = wallets.map { + WalletIdBody(it) + }, + ).getOrThrow() + } + + override suspend fun isNotificationsEnabledForWallet(walletId: String): Boolean = withContext(dispatchers.io) { + tangemTechApi.getWalletById(walletId).getOrThrow().notifyStatus + } + + override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) { + tangemTechApi.updateWallet( + walletId, + WalletBody(name = walletName), + ).getOrThrow() + } + + override suspend fun getWalletName(walletId: String): String? = withContext(dispatchers.io) { + tangemTechApi.getWalletById(walletId).getOrThrow().name + } + + override suspend fun sendPushToken(appId: String, pushToken: String) { + withContext(dispatchers.io) { + tangemTechApi.updatePushTokenForApplicationId( + appId, + NotificationApplicationCreateBody( + pushToken = pushToken, + ), + ).getOrThrow() + } + } + + override suspend fun getEligibleNetworks(): List = withContext(dispatchers.io) { + tangemTechApi.getEligibleNetworksForPushNotifications().getOrThrow().map { + NotificationsEligibleNetworkConverter.convert(it) + } + } +} \ No newline at end of file diff --git a/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt b/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt new file mode 100644 index 0000000000..471dd0b137 --- /dev/null +++ b/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt @@ -0,0 +1,15 @@ +package com.tangem.data.notifications.converters + +import com.tangem.datasource.api.tangemTech.models.CryptoNetworkResponse +import com.tangem.domain.notifications.models.NotificationsEligibleNetwork +import com.tangem.utils.converter.Converter + +internal object NotificationsEligibleNetworkConverter : Converter { + override fun convert(value: CryptoNetworkResponse): NotificationsEligibleNetwork { + return NotificationsEligibleNetwork( + id = value.id.toString(), + networkId = value.networkId, + name = value.name, + ) + } +} \ No newline at end of file diff --git a/data/notifications/src/main/java/com/tangem/data/notifications/di/NotificationsModule.kt b/data/notifications/src/main/java/com/tangem/data/notifications/di/NotificationsModule.kt new file mode 100644 index 0000000000..555ac66802 --- /dev/null +++ b/data/notifications/src/main/java/com/tangem/data/notifications/di/NotificationsModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.notifications.di + +import com.tangem.data.notifications.DefaultNotificationsRepository +import com.tangem.domain.notifications.repository.NotificationsRepository +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 NotificationsModule { + + @Binds + @Singleton + fun bindNotificationsRepository(repository: DefaultNotificationsRepository): NotificationsRepository +} \ No newline at end of file diff --git a/data/notifications/src/test/java/com/tangem/data/notifications/DefaultNotificationsRepositoryTest.kt b/data/notifications/src/test/java/com/tangem/data/notifications/DefaultNotificationsRepositoryTest.kt new file mode 100644 index 0000000000..65c9a1220d --- /dev/null +++ b/data/notifications/src/test/java/com/tangem/data/notifications/DefaultNotificationsRepositoryTest.kt @@ -0,0 +1,256 @@ +package com.tangem.data.notifications + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.utils.info.AppInfoProvider +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.models.* +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultNotificationsRepositoryTest { + private val tangemTechApi: TangemTechApi = mockk() + private val appInfoProvider: AppInfoProvider = mockk() + private val preferencesDataStore: DataStore = mockk() + private val appPreferencesStore = AppPreferencesStore( + moshi = Moshi.Builder().build(), + dispatchers = TestingCoroutineDispatcherProvider(), + preferencesDataStore = preferencesDataStore, + ) + private val repository = DefaultNotificationsRepository( + tangemTechApi = tangemTechApi, + appInfoProvider = appInfoProvider, + appPreferencesStore = appPreferencesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `GIVEN valid push token WHEN createApplicationId THEN returns application id`() = runTest { + // GIVEN + val pushToken = "test-push-token" + val expectedAppId = "test-app-id" + val expectedAppIdResponse = NotificationApplicationIdResponse( + appId = expectedAppId, + ) + coEvery { appInfoProvider.platform } returns "android" + coEvery { appInfoProvider.device } returns "test-device" + coEvery { appInfoProvider.osVersion } returns "11" + coEvery { appInfoProvider.language } returns "en" + coEvery { appInfoProvider.timezone } returns "UTC" + coEvery { tangemTechApi.createApplicationId(any()) } returns ApiResponse.Success( + expectedAppIdResponse, + ) + + // WHEN + val result = repository.createApplicationId(pushToken) + + // THEN + assertThat(result).isEqualTo(expectedAppId) + coVerify { + tangemTechApi.createApplicationId( + NotificationApplicationCreateBody( + platform = "android", + device = "test-device", + systemVersion = "11", + language = "en", + timezone = "UTC", + pushToken = pushToken, + ), + ) + } + } + + @Test + fun `GIVEN application id WHEN saveApplicationId THEN stores it in preferences`() = runTest { + // GIVEN + val appId = "test-app-id" + val preferences = mockk(relaxed = true) + coEvery { preferencesDataStore.updateData(any()) } returns preferences + + // WHEN + repository.saveApplicationId(appId) + + // THEN + coVerify { preferencesDataStore.updateData(any()) } + } + + @Test + fun `GIVEN stored application id WHEN getApplicationId THEN returns it`() = runTest { + // GIVEN + val expectedAppId = "test-app-id" + val preferences = mockk(relaxed = true) + val key = stringPreferencesKey(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY.name) + every { preferences[key] } returns expectedAppId + coEvery { preferencesDataStore.data } returns flowOf(preferences) + + // WHEN + val result = repository.getApplicationId() + + // THEN + assertThat(result).isEqualTo(expectedAppId) + } + + @Test + fun `GIVEN wallet id and enabled status WHEN setNotificationsEnabledForWallet THEN updates notification status`() = + runTest { + // GIVEN + val walletId = "test-wallet-id" + val enabled = true + coEvery { + tangemTechApi.setNotificationsEnabled( + walletId, + WalletBody(notifyStatus = enabled), + ) + } returns ApiResponse.Success(Unit) + + // WHEN + repository.setNotificationsEnabledForWallet(walletId, enabled) + + // THEN + coVerify { tangemTechApi.setNotificationsEnabled(walletId, WalletBody(notifyStatus = enabled)) } + } + + @Test + fun `GIVEN application id and wallet list WHEN associateApplicationIdWithWallets THEN associates them`() = runTest { + // GIVEN + val appId = "test-app-id" + val wallets = listOf("wallet1", "wallet2") + coEvery { + tangemTechApi.associateApplicationIdWithWallets( + appId, + wallets.map { WalletIdBody(it) }, + ) + } returns ApiResponse.Success(Unit) + + // WHEN + repository.associateApplicationIdWithWallets(appId, wallets) + + // THEN + coVerify { tangemTechApi.associateApplicationIdWithWallets(appId, wallets.map { WalletIdBody(it) }) } + } + + @Test + fun `GIVEN wallet id WHEN isNotificationsEnabledForWallet THEN returns notification status`() = runTest { + // GIVEN + val walletId = "test-wallet-id" + val expectedStatus = true + coEvery { tangemTechApi.getWalletById(walletId) } returns ApiResponse.Success( + WalletResponse( + notifyStatus = expectedStatus, + name = "Test Wallet", + id = walletId, + ), + ) + + // WHEN + val result = repository.isNotificationsEnabledForWallet(walletId) + + // THEN + assertThat(result).isEqualTo(expectedStatus) + } + + @Test + fun `GIVEN wallet id and name WHEN setWalletName THEN updates wallet name`() = runTest { + // GIVEN + val walletId = "test-wallet-id" + val walletName = "Test Wallet" + coEvery { + tangemTechApi.updateWallet( + walletId, + WalletBody(name = walletName), + ) + } returns ApiResponse.Success(Unit) + + // WHEN + repository.setWalletName(walletId, walletName) + + // THEN + coVerify { tangemTechApi.updateWallet(walletId, WalletBody(name = walletName)) } + } + + @Test + fun `GIVEN wallet id WHEN getWalletName THEN returns wallet name`() = runTest { + // GIVEN + val walletId = "test-wallet-id" + val expectedName = "Test Wallet" + coEvery { tangemTechApi.getWalletById(walletId) } returns ApiResponse.Success( + WalletResponse( + notifyStatus = false, + name = expectedName, + id = walletId, + ), + ) + + // WHEN + val result = repository.getWalletName(walletId) + + // THEN + assertThat(result).isEqualTo(expectedName) + } + + @Test + fun `GIVEN application id and push token WHEN sendPushToken THEN updates push token`() = runTest { + // GIVEN + val appId = "test-app-id" + val pushToken = "test-push-token" + coEvery { + tangemTechApi.updatePushTokenForApplicationId( + appId, + NotificationApplicationCreateBody(pushToken = pushToken), + ) + } returns ApiResponse.Success(appId) + + // WHEN + repository.sendPushToken(appId, pushToken) + + // THEN + coVerify { + tangemTechApi.updatePushTokenForApplicationId( + appId, + NotificationApplicationCreateBody(pushToken = pushToken), + ) + } + } + + @Test + fun `GIVEN eligible networks WHEN getEligibleNetworks THEN returns converted networks`() = runTest { + // GIVEN + val expectedNetworks = listOf( + CryptoNetworkResponse( + id = 1, + name = "Ethereum", + networkId = "Ethereum", + ), + CryptoNetworkResponse( + id = 2, + name = "Bitcoin", + networkId = "bitcoin", + ), + ) + coEvery { tangemTechApi.getEligibleNetworksForPushNotifications() } returns ApiResponse.Success( + expectedNetworks, + ) + + // WHEN + val result = repository.getEligibleNetworks() + + // THEN + assertThat(result).hasSize(2) + assertThat(result[0]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[0])) + assertThat(result[1]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[1])) + coVerify { tangemTechApi.getEligibleNetworksForPushNotifications() } + } +} \ 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 578611fb04..479cb8c590 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 @@ -24,6 +24,7 @@ import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.exchangeservice.swap.ExpressUtils import com.tangem.datasource.local.onramp.countries.OnrampCountriesStore import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore @@ -43,6 +44,7 @@ import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.NonCancellable @@ -82,10 +84,16 @@ internal class DefaultOnrampRepository( override fun getCurrencies(): Flow> = currenciesStore.get(CURRENCIES_KEY) - override suspend fun fetchCurrencies() = withContext(dispatchers.io) { + override suspend fun fetchCurrencies(userWallet: UserWallet) = withContext(dispatchers.io) { if (!currenciesStore.getSyncOrNull(CURRENCIES_KEY).isNullOrEmpty()) return@withContext - val result = onrampApi.getCurrencies() + val result = onrampApi.getCurrencies( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ) .getOrThrow() .map(currencyConverter::convert) @@ -98,10 +106,16 @@ internal class DefaultOnrampRepository( return countriesStore.getSyncOrNull(COUNTRIES_KEY) } - override suspend fun fetchCountries(): List = withContext(dispatchers.io) { + override suspend fun fetchCountries(userWallet: UserWallet): List = withContext(dispatchers.io) { if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList() - val result = onrampApi.getCountries() + val result = onrampApi.getCountries( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ) .getOrThrow() .map(countryConverter::convert) @@ -110,14 +124,27 @@ internal class DefaultOnrampRepository( result } - override suspend fun getCountryByIp(): OnrampCountry = withContext(dispatchers.io) { - onrampApi.getCountryByIp() + override suspend fun getCountryByIp(userWallet: UserWallet): OnrampCountry = withContext(dispatchers.io) { + onrampApi.getCountryByIp( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ) .getOrThrow() .let(countryConverter::convert) } - override suspend fun getStatus(txId: String): OnrampStatus = withContext(dispatchers.io) { - onrampApi.getStatus(txId) + override suspend fun getStatus(userWallet: UserWallet, txId: String): OnrampStatus = withContext(dispatchers.io) { + onrampApi.getStatus( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + txId = txId, + ) .getOrThrow() .let(statusConverter::convert) } @@ -161,11 +188,19 @@ internal class DefaultOnrampRepository( .map { it?.let(countryConverter::convert) } } - override suspend fun fetchPaymentMethodsIfAbsent() = withContext(dispatchers.io) { + override suspend fun fetchPaymentMethodsIfAbsent(userWallet: UserWallet) = withContext(dispatchers.io) { if (paymentMethodsStore.contains(PAYMENT_METHODS_KEY)) return@withContext val response = safeApiCall( - call = { onrampApi.getPaymentMethods().bind() }, + call = { + onrampApi.getPaymentMethods( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ).bind() + }, onError = { Timber.w(it, "Unable to fetch onramp payment methods") throw it @@ -174,100 +209,123 @@ internal class DefaultOnrampRepository( paymentMethodsStore.store(PAYMENT_METHODS_KEY, response.removeApplePay()) } - override suspend fun fetchPairs(currency: OnrampCurrency, country: OnrampCountry, cryptoCurrency: CryptoCurrency) = - withContext(dispatchers.io) { - val onrampPairs = async { - safeApiCall( - call = { - onrampApi.getPairs( - body = OnrampPairsRequest( - fromCurrencyCode = currency.code, - countryCode = country.code, - to = listOf( - OnrampDestinationDTO( - contractAddress = cryptoCurrency.getContractAddress(), - network = cryptoCurrency.network.backendId, - ), + override suspend fun fetchPairs( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ) = withContext(dispatchers.io) { + val onrampPairs = async { + 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 providers = async { - safeApiCall( - call = { expressApi.getProviders().bind() }, - onError = { - Timber.w(it, "Unable to fetch express providers") - throw it - }, - ) - } - storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await()) + ), + ).bind() + }, + onError = { + Timber.w(it, "Unable to fetch onramp pairs") + throw it + }, + ) } + val providers = async { + safeApiCall( + call = { + expressApi.getProviders( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ).bind() + }, + onError = { + Timber.w(it, "Unable to fetch express providers") + throw it + }, + ) + } + storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await()) + } - override suspend fun fetchQuotes(cryptoCurrency: CryptoCurrency, amount: Amount) = withContext(dispatchers.io) { - val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) { - "Unable to get pairs. At this point they must not be null." - } - val amountValue = requireNotNull(amount.value) { "Amount value must not be null" } - val fromAmount = amountValue.movePointRight(amount.decimals).toString() - val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" } - val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" } - val fromOnrampAmount = OnrampAmount( - value = fromAmount - .toBigDecimalOrDefault() - .movePointLeft(currency.precision), - decimals = currency.precision, - symbol = amount.currencySymbol, - ) - val quotes: List = pairs.flatMap { pair -> - pair.providers.flatMap { provider -> - provider.paymentMethods.map { paymentMethod -> - async { - safeApiCall( - call = { - val response = onrampApi.getQuote( - fromCurrencyCode = currency.code, - fromPrecision = currency.precision, - toContractAddress = cryptoCurrency.getContractAddress(), - toNetwork = cryptoCurrency.network.backendId, - paymentMethod = paymentMethod.id, - countryCode = country.code, - fromAmount = fromAmount, - toDecimals = cryptoCurrency.decimals, - providerId = provider.id, - ).bind() - OnrampQuote.Data( - fromAmount = fromOnrampAmount, - toAmount = convertToAmount(response.toAmount, cryptoCurrency), - minFromAmount = convertToAmount(response.minFromAmount, cryptoCurrency), - maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency), - paymentMethod = paymentMethod, - provider = provider, - countryCode = response.countryCode, - ) - }, - onError = { error -> - convertQuoteError( - error = error, - paymentMethod = paymentMethod, - provider = provider, - fromOnrampAmount = fromOnrampAmount, - countryCode = country.code, - ) - }, - ) + override suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) = + withContext(dispatchers.io) { + val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) { + "Unable to get pairs. At this point they must not be null." + } + val amountValue = requireNotNull(amount.value) { "Amount value must not be null" } + val fromAmount = amountValue.movePointRight(amount.decimals).toString() + val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" } + val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" } + val fromOnrampAmount = OnrampAmount( + value = fromAmount + .toBigDecimalOrDefault() + .movePointLeft(currency.precision), + decimals = currency.precision, + symbol = amount.currencySymbol, + ) + val quotes: List = pairs.flatMap { pair -> + pair.providers.flatMap { provider -> + provider.paymentMethods.map { paymentMethod -> + async { + safeApiCall( + call = { + val response = onrampApi.getQuote( + fromCurrencyCode = currency.code, + fromPrecision = currency.precision, + toContractAddress = cryptoCurrency.getContractAddress(), + toNetwork = cryptoCurrency.network.backendId, + paymentMethod = paymentMethod.id, + countryCode = country.code, + fromAmount = fromAmount, + toDecimals = cryptoCurrency.decimals, + providerId = provider.id, + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ).bind() + OnrampQuote.Data( + fromAmount = fromOnrampAmount, + toAmount = convertToAmount(response.toAmount, cryptoCurrency), + minFromAmount = convertToAmount(response.minFromAmount, cryptoCurrency), + maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency), + paymentMethod = paymentMethod, + provider = provider, + countryCode = response.countryCode, + ) + }, + onError = { error -> + convertQuoteError( + error = error, + paymentMethod = paymentMethod, + provider = provider, + fromOnrampAmount = fromOnrampAmount, + countryCode = country.code, + ) + }, + ) + } } } - } - }.awaitAll().filterNotNull() - quotesStore.store(QUOTES_KEY, quotes) - } + }.awaitAll().filterNotNull() + quotesStore.store(QUOTES_KEY, quotes) + } override fun getQuotes(): Flow> { return quotesStore.get(QUOTES_KEY) @@ -278,14 +336,14 @@ internal class DefaultOnrampRepository( } override suspend fun getOnrampData( - userWalletId: UserWalletId, + userWallet: UserWallet, cryptoCurrency: CryptoCurrency, quote: OnrampProviderWithQuote.Data, isDarkTheme: Boolean, ): OnrampTransaction = withContext(dispatchers.io) { try { val address = requireNotNull( - value = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network), + value = walletManagersFacade.getDefaultAddress(userWallet.walletId, cryptoCurrency.network), lazyMessage = { "Address must not be null" }, ) val fromAmountString = quote.fromAmount.value.movePointRight(quote.fromAmount.decimals).toString() @@ -309,6 +367,11 @@ internal class DefaultOnrampRepository( language = null, theme = getTheme(isDarkTheme), requestId = requestId, + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), ).bind() }, onError = { e -> @@ -326,7 +389,7 @@ internal class DefaultOnrampRepository( txId = data.txId, quote = quote, onrampDataJson = dataJson, - userWalletId = userWalletId, + userWalletId = userWallet.walletId, currency = currency, cryptoCurrency = cryptoCurrency, residency = country.name, diff --git a/data/quotes/.gitignore b/data/quotes/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/quotes/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/quotes/build.gradle.kts b/data/quotes/build.gradle.kts new file mode 100644 index 0000000000..9005f39528 --- /dev/null +++ b/data/quotes/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.quotes" +} + +dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + + implementation(projects.data.common) + implementation(projects.data.tokens) + + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.quotes) + implementation(projects.domain.wallets.models) + + implementation(deps.androidx.datastore) + implementation(deps.timber) + + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt new file mode 100644 index 0000000000..a39da5f600 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt @@ -0,0 +1,25 @@ +package com.tangem.data.quotes + +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import javax.inject.Inject + +/** + * Default implementation of [QuotesRepositoryV2] + * + * @property quotesStore quotes store + * +[REDACTED_AUTHOR] + */ +internal class DefaultQuotesRepositoryV2 @Inject constructor( + private val quotesStore: QuotesStoreV2, +) : QuotesRepositoryV2 { + + override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set? { + return quotesStore.getAllSyncOrNull()?.mapTo(hashSetOf()) { + it.takeIf { it.rawCurrencyId in currenciesIds } ?: Quote.Empty(it.rawCurrencyId) + } + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt new file mode 100644 index 0000000000..5a5eb2ecfd --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher +import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteUpdater +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 QuoteFetcherModule { + + @Binds + @Singleton + fun bindMultiQuoteFetcher(impl: DefaultMultiQuoteFetcher): MultiQuoteFetcher + + @Binds + @Singleton + fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt new file mode 100644 index 0000000000..0fc3d0faed --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.single.DefaultSingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteProducer +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 QuoteProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleQuoteProducerFactory(impl: DefaultSingleQuoteProducer.Factory): SingleQuoteProducer.Factory +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt new file mode 100644 index 0000000000..af8188e5bc --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt @@ -0,0 +1,42 @@ +package com.tangem.data.quotes.di + +import androidx.datastore.core.DataStore +import com.tangem.data.quotes.store.CurrencyIdWithQuote +import com.tangem.data.quotes.store.DefaultQuotesStoreV2 +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier +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 QuoteSupplierModule { + + @Provides + @Singleton + fun provideQuotesStoreV2( + persistenceQuotesStore: DataStore, + dispatchers: CoroutineDispatcherProvider, + ): QuotesStoreV2 { + return DefaultQuotesStoreV2( + runtimeStore = RuntimeSharedStore(), + persistenceDataStore = persistenceQuotesStore, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideSingleQuoteSupplier(factory: SingleQuoteProducer.Factory): SingleQuoteSupplier { + return object : SingleQuoteSupplier( + factory = factory, + keyCreator = { "single_quote_${it.rawCurrencyId.value}" }, + ) {} + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt new file mode 100644 index 0000000000..e3c96931a8 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.DefaultQuotesRepositoryV2 +import com.tangem.domain.quotes.QuotesRepositoryV2 +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 QuotesDataModule { + + @Binds + @Singleton + fun bindQuotesRepositoryV2(impl: DefaultQuotesRepositoryV2): QuotesRepositoryV2 +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt new file mode 100644 index 0000000000..e9fa631d23 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt @@ -0,0 +1,85 @@ +package com.tangem.data.quotes.multi + +import arrow.core.Either +import com.tangem.data.common.api.safeApiCallWithTimeout +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [MultiQuoteFetcher] + * + * @property tangemTechApi tangemTech api + * @property appCurrencyResponseStore app currency response store + * @property quotesStore quotes store + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +@Singleton +internal class DefaultMultiQuoteFetcher @Inject constructor( + private val tangemTechApi: TangemTechApi, + private val appCurrencyResponseStore: AppCurrencyResponseStore, + private val quotesStore: QuotesStoreV2, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiQuoteFetcher { + + private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter() + + override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) { + if (params.currenciesIds.isEmpty()) { + Timber.d("No currencies to fetch quotes for") + return@catchOn + } + + quotesStore.refresh(currenciesIds = params.currenciesIds) + + val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies( + currenciesIds = params.currenciesIds.mapTo( + destination = hashSetOf(), + transform = CryptoCurrency.RawID::value, + ), + ) + + val appCurrencyId = getAppCurrencyId(params = params) + val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",") + + val response = safeApiCallWithTimeout( + call = { tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() }, + onError = { error -> throw error }, + ) + + val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies( + response = response, + filteredIds = replacementIdsResult.idsFiltered, + ) + + quotesStore.storeActual(values = updatedResponse.quotes) + } + .onLeft { + Timber.e(it) + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String { + val appCurrencyId = params.appCurrencyId + ?: appCurrencyResponseStore.getSyncOrNull()?.id + + if (appCurrencyId.isNullOrBlank()) { + val exception = IllegalStateException("Unable to get AppCurrency for updating quotes") + Timber.e(exception) + + throw exception + } + + return appCurrencyId + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt new file mode 100644 index 0000000000..06e492857e --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt @@ -0,0 +1,86 @@ +package com.tangem.data.quotes.multi + +import androidx.annotation.VisibleForTesting +import arrow.core.left +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.core.utils.EitherFlow +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteUpdater +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes + * + * @property appCurrencyResponseStore app currency response store + * @property quotesStore quotes store + * @property multiQuoteFetcher multi quote fetcher + * @param dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +@Singleton +internal class DefaultMultiQuoteUpdater @Inject constructor( + private val appCurrencyResponseStore: AppCurrencyResponseStore, + private val quotesStore: QuotesStoreV2, + private val multiQuoteFetcher: MultiQuoteFetcher, + dispatchers: CoroutineDispatcherProvider, +) : MultiQuoteUpdater { + + private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default) + private val updaterHolder = JobHolder() + + override fun subscribe() { + Timber.d("Subscribe on quotes updates") + getMultiQuoteUpdates() + .launchIn(coroutineScope) + .saveIn(updaterHolder) + } + + override fun unsubscribe() { + Timber.e("Unsubscribe from quotes updates") + updaterHolder.cancel() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getMultiQuoteUpdates(): EitherFlow { + return appCurrencyResponseStore.get() + .drop(count = 1) // skip initial value + .distinctUntilChanged() + .filterNotNull() + .mapLatest { appCurrency -> + val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty() + .mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId) + + multiQuoteFetcher( + params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id), + ) + .onLeft(Timber::e) + } + .retryWhen { cause, _ -> + Timber.e("Retry updating quotes: $cause") + + emit(cause.left()) + + delay(timeMillis = 2000) + true + } + } + + @VisibleForTesting(VisibleForTesting.NONE) + fun getMultiQuoteUpdatesFlow(): EitherFlow = getMultiQuoteUpdates() + + @VisibleForTesting(VisibleForTesting.NONE) + fun getUpdaterJobHolder(): JobHolder = updaterHolder +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt new file mode 100644 index 0000000000..d8450243b6 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt @@ -0,0 +1,40 @@ +package com.tangem.data.quotes.single + +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleQuoteProducer] + * + * @property params params + * @property quotesStore quotes store + */ +internal class DefaultSingleQuoteProducer @AssistedInject constructor( + @Assisted val params: SingleQuoteProducer.Params, + private val quotesStore: QuotesStoreV2, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleQuoteProducer { + + override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + + override fun produce(): Flow { + return quotesStore.get() + .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleQuoteProducer.Factory { + override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt new file mode 100644 index 0000000000..9fbbe97e50 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt @@ -0,0 +1,91 @@ +package com.tangem.data.quotes.store + +import androidx.datastore.core.DataStore +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.quote.converter.QuoteConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +internal typealias CurrencyIdWithQuote = Map + +/** + * Default implementation of [QuotesStoreV2] + * + * @property runtimeStore runtime store + * @property persistenceDataStore persistence store + * @param dispatchers dispatchers + */ +internal class DefaultQuotesStoreV2( + private val runtimeStore: RuntimeSharedStore>, + private val persistenceDataStore: DataStore, + dispatchers: CoroutineDispatcherProvider, +) : QuotesStoreV2 { + + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + init { + scope.launch { + val cachedStatuses = persistenceDataStore.data.firstOrNull() + + if (cachedStatuses.isNullOrEmpty()) return@launch + + runtimeStore.store( + value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries), + ) + } + } + + override fun get(): Flow> = runtimeStore.get() + + override suspend fun getAllSyncOrNull(): Set? = runtimeStore.getSyncOrNull() + + override suspend fun refresh(currenciesIds: Set) { + updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE) + } + + override suspend fun storeActual(values: Map) { + coroutineScope { + launch { + val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries) + storeInRuntimeStore(values = quotes) + } + launch { storeInPersistenceStore(values = values) } + } + } + + override suspend fun storeError(currenciesIds: Set) { + updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE) + } + + private suspend fun updateStatusSourceInRuntime(currenciesIds: Set, source: StatusSource) { + runtimeStore.update(default = emptySet()) { stored -> + val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id -> + val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id) + + quote.copySealed(source = source) + } + + stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId } + } + } + + private suspend fun storeInRuntimeStore(values: Set) { + runtimeStore.update(default = emptySet()) { saved -> + saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId } + } + } + + private suspend fun storeInPersistenceStore(values: Map) { + persistenceDataStore.updateData { storedQuotes -> storedQuotes + values } + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt new file mode 100644 index 0000000000..ed31f3abf9 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt @@ -0,0 +1,25 @@ +package com.tangem.data.quotes.store + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import kotlinx.coroutines.flow.Flow + +/** Store of [Quote]'es set */ +internal interface QuotesStoreV2 { + + /** Get flow of quotes */ + fun get(): Flow> + + /** Get all quotes synchronously or null */ + suspend fun getAllSyncOrNull(): Set? + + /** Refresh status of [currenciesIds] */ + suspend fun refresh(currenciesIds: Set) + + /** Store actual map of currency ids and quotes [values] */ + suspend fun storeActual(values: Map) + + /** Store error for [currenciesIds] */ + suspend fun storeError(currenciesIds: Set) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt new file mode 100644 index 0000000000..3e85896a19 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt @@ -0,0 +1,214 @@ +package com.tangem.data.quotes.multi + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.data.quotes.store.QuotesStoreV2 +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.CurrenciesResponse +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiQuoteFetcherTest { + + private val tangemTechApi = mockk(relaxed = true) + private val appCurrencyResponseStore = mockk(relaxed = true) + private val quotesStore = mockk(relaxed = true) + + private val fetcher = DefaultMultiQuoteFetcher( + tangemTechApi = tangemTechApi, + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStore = quotesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `fetch quotes successfully`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC,ETH" + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + } returns ApiResponse.Success(successResponse) + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + appCurrencyResponseStore.getSyncOrNull() + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeActual(values = successResponse.quotes) + } + + coVerify(inverse = true) { + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null) + + val actual = fetcher(params) + + coVerify(inverse = true) { + quotesStore.refresh(currenciesIds = any()) + appCurrencyResponseStore.getSyncOrNull() + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest { + val appCurrencyId = "usd" + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) + + val coinIds = "BTC,ETH" + coEvery { + tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds) + } returns ApiResponse.Success(successResponse) + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeActual(values = successResponse.quotes) + } + + coVerify(inverse = true) { + appCurrencyResponseStore.getSyncOrNull() + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest { + val appCurrencyId = "" + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + appCurrencyResponseStore.getSyncOrNull() + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat() + .isEqualTo("Unable to get AppCurrency for updating quotes") + } + + @Test + fun `fetch quotes failure because api request failed`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC,ETH" + + @Suppress("UNCHECKED_CAST") + val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse + coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + appCurrencyResponseStore.getSyncOrNull() + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + @Test + fun `fetch quotes failure because app currency not found`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + appCurrencyResponseStore.getSyncOrNull() + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + private companion object { + + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + val usdAppCurrency = CurrenciesResponse.Currency( + id = "USD".lowercase(), + code = "USD", + name = "US Dollar", + unit = "$", + type = "fiat", + rateBTC = "", + ) + + val successResponse = QuotesResponse( + quotes = mapOf( + "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), + "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN), + ), + ) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt new file mode 100644 index 0000000000..f14962f52b --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt @@ -0,0 +1,169 @@ +package com.tangem.data.quotes.multi + +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiQuoteUpdaterTest { + + private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk() + private val quotesStore: QuotesStoreV2 = mockk() + private val multiQuoteFetcher: MultiQuoteFetcher = mockk() + + private val multiQuoteUpdater = DefaultMultiQuoteUpdater( + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStore = quotesStore, + multiQuoteFetcher = multiQuoteFetcher, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test that initial app currency is skipped`() = runTest { + val appCurrencyFlow = flowOf(null, usdAppCurrency) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() + + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteFetcher(params) } returns Unit.right() + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values = getEmittedValues(actual) + Truth.assertThat(values).isEqualTo(listOf(Unit.right())) + + coVerifyOrder { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(params) + } + } + + @Test + fun `test that flow is filtered the same status`() = runTest { + val appCurrencyFlow = flowOf(usdAppCurrency, usdAppCurrency) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() + + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteFetcher(params) } returns Unit.right() + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values = getEmittedValues(actual) + Truth.assertThat(values).isEqualTo(listOf(Unit.right())) + + coVerifyOrder { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(params) + } + } + + @Test + fun `test that flow is filtered the null`() = runTest { + val appCurrencyFlow = flowOf(null, null) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values = getEmittedValues(actual) + Truth.assertThat(values.size).isEqualTo(0) + + coVerify(inverse = true) { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(any()) + } + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + + val innerFlow = MutableStateFlow(value = false) + val appCurrencyFlow = flow { + if (innerFlow.value) { + emitAll(flowOf(null, usdAppCurrency)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() + + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteFetcher(params) } returns Unit.right() + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values1 = getEmittedValues(actual) + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first().isLeft()).isTrue() + Truth.assertThat(values1.first().leftOrNull()).isInstanceOf(exception::class.java) + Truth.assertThat(values1.first().leftOrNull()).hasMessageThat().isEqualTo(exception.message) + + coVerify(inverse = true) { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(any()) + } + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + Truth.assertThat(values2).isEqualTo(listOf(Unit.right())) + + coVerifyOrder { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(params) + } + } + + @Test + fun `subscribe and unsubscribe successfully`() { + every { appCurrencyResponseStore.get() } returns emptyFlow() + + val updaterJobHolder = multiQuoteUpdater.getUpdaterJobHolder() + Truth.assertThat(updaterJobHolder.isEmpty()).isTrue() + + multiQuoteUpdater.subscribe() + + Truth.assertThat(updaterJobHolder.isEmpty()).isFalse() + + multiQuoteUpdater.unsubscribe() + + Truth.assertThat(updaterJobHolder.isEmpty()).isTrue() + } + + private companion object { + val usdAppCurrency = CurrenciesResponse.Currency( + id = "USD".lowercase(), + code = "USD", + name = "US Dollar", + unit = "$", + type = "fiat", + rateBTC = "", + ) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt new file mode 100644 index 0000000000..f228d65673 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt @@ -0,0 +1,177 @@ +package com.tangem.data.quotes.single + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.models.StatusSource +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleQuoteProducerTest { + + private val params = SingleQuoteProducer.Params( + rawCurrencyId = CryptoCurrency.RawID(value = "BTC"), + ) + + private val quotesStore = mockk() + + private val producer = DefaultSingleQuoteProducer( + params = params, + quotesStore = quotesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test that flow is mapped for network from params`() = runTest { + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + val storeQuote = flowOf( + setOf( + status, + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produce() + + verify { quotesStore.get() } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(status)) + } + + @Test + fun `test that flow is updated if quote is updated`() = runTest { + val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + // first emit + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + storeQuote.emit(value = setOf(status)) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + val updatedStatus = Quote.Value( + rawCurrencyId = params.rawCurrencyId, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + storeQuote.emit(value = setOf(updatedStatus)) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(2) + Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus)) + } + + @Test + fun `test that flow is filtered the same status`() = runTest { + val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + // first emit + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + storeQuote.emit(value = setOf(status)) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + storeQuote.emit(value = setOf(status)) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(status)) + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + val status = Quote.Value( + rawCurrencyId = params.rawCurrencyId, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + + val innerFlow = MutableStateFlow(value = false) + val storeQuote = flow { + if (innerFlow.value) { + emit(setOf(status)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus)) + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(status)) + } + + @Test + fun `test if flow doesn't contain network from params`() = runTest { + val storeFlow = flowOf( + setOf( + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + every { quotesStore.get() } returns storeFlow + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(0) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt new file mode 100644 index 0000000000..d5a517f5e2 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt @@ -0,0 +1,92 @@ +package com.tangem.data.quotes.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreGetMethodTest { + + private val runtimeStore = RuntimeSharedStore>() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test get if runtime store is empty`() = runTest { + val actual = store.get() + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains empty set`() = runTest { + runtimeStore.store(value = emptySet()) + + val actual = store.get() + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + @Test + fun `test get if runtime store is not empty`() = runTest { + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain())) + + val actual = store.get() + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain()))) + } + + @Test + fun `test getAllSyncOrNull if runtime store is empty`() = runTest { + val actual = store.getAllSyncOrNull() + + Truth.assertThat(actual).isEqualTo(null) + } + + @Test + fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest { + runtimeStore.store(value = emptySet()) + + val actual = store.getAllSyncOrNull() + + Truth.assertThat(actual).isEqualTo(emptySet()) + } + + @Test + fun `test getAllSyncOrNull if runtime store is not empty`() = runTest { + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain())) + + val actual = store.getAllSyncOrNull() + + val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain()) + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt new file mode 100644 index 0000000000..2452a66004 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt @@ -0,0 +1,82 @@ +package com.tangem.data.quotes.store + +import androidx.datastore.core.DataStore +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreInitializationTest { + + @Test + fun `test initialization if cache store is empty`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore: DataStore = mockk() + + every { persistenceStore.data } returns emptyFlow() + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store contains empty map`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store is not empty`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + persistenceStore.updateData { + it.toMutableMap().apply { + this += btcQuote + this += ethQuote + } + } + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val expected = setOf( + btcQuote.toDomain(source = StatusSource.CACHE), + ethQuote.toDomain(source = StatusSource.CACHE), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt new file mode 100644 index 0000000000..407b640fd9 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt @@ -0,0 +1,131 @@ +package com.tangem.data.quotes.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreUpdateMethodsTest { + + private val runtimeStore = RuntimeSharedStore>() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `refresh if runtime store is empty`() = runTest { + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + store.refresh(currenciesIds = currenciesIds) + + val runtimeExpected = currenciesIds.map(Quote::Empty).toSet() + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh if runtime store contains quote with this id`() = runTest { + val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + .toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL) + + runtimeStore.store(value = setOf(quote)) + + store.refresh(currenciesIds = setOf(quote.rawCurrencyId)) + + val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE)) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store actual if runtime and cache stores contain quotes with this id`() = runTest { + val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + + runtimeStore.store( + value = setOf( + prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), + ), + ) + + persistenceStore.updateData { + it.toMutableMap().apply { + put("BTC", prevStatus) + } + } + + val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN) + + store.storeActual(values = mapOf("BTC" to newStatus)) + + val runtimeExpected = setOf( + newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL), + ) + val persistenceExpected = mapOf("BTC" to newStatus) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected) + } + + @Test + fun `store error if runtime store is empty`() = runTest { + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + store.storeError(currenciesIds = currenciesIds) + + val runtimeExpected = currenciesIds.map(Quote::Empty).toSet() + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store error if runtime store contains status with this network`() = runTest { + val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + + runtimeStore.store( + value = setOf( + status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE), + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + store.storeError( + currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ), + ) + + val runtimeExpected = setOf( + status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } +} \ No newline at end of file diff --git a/data/staking/build.gradle.kts b/data/staking/build.gradle.kts index 9af26ac76e..b4d8282fac 100644 --- a/data/staking/build.gradle.kts +++ b/data/staking/build.gradle.kts @@ -62,4 +62,11 @@ dependencies { } // endregion + + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(tangemDeps.card.core) + testImplementation(projects.common.test) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index 198ecb2e5a..03e1b51935 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -24,6 +24,9 @@ import com.tangem.data.staking.converters.transaction.GasEstimateConverter import com.tangem.data.staking.converters.transaction.StakingTransactionConverter import com.tangem.data.staking.converters.transaction.StakingTransactionStatusConverter import com.tangem.data.staking.converters.transaction.StakingTransactionTypeConverter +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.data.staking.utils.StakingIdFactory.Companion.integrationIdMap import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.stakekit.StakeKitApi @@ -64,7 +67,6 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber @@ -75,11 +77,13 @@ internal class DefaultStakingRepository( private val stakeKitApi: StakeKitApi, private val stakingYieldsStore: StakingYieldsStore, private val stakingBalanceStore: StakingBalanceStore, + private val stakingBalanceStoreV2: YieldsBalancesStore, private val cacheRegistry: CacheRegistry, private val dispatchers: CoroutineDispatcherProvider, private val walletManagersFacade: WalletManagersFacade, private val getUserWalletUseCase: GetUserWalletUseCase, private val stakingFeatureToggles: StakingFeatureToggles, + private val stakingIdFactory: StakingIdFactory, moshi: Moshi, ) : StakingRepository { @@ -428,7 +432,7 @@ internal class DefaultStakingRepository( } }.cancellable() - override suspend fun getSingleYieldBalanceSync( + override suspend fun getSingleYieldBalanceSyncLegacy( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): YieldBalance = withContext(dispatchers.io) { @@ -446,6 +450,20 @@ internal class DefaultStakingRepository( ?: YieldBalance.Error(integrationId, address) } + override suspend fun getSingleYieldBalanceSync( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): YieldBalance { + val stakingId = stakingIdFactory.createForDefault( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ) ?: error("Could not create stakingId") + + return stakingBalanceStoreV2.getSyncOrNull(userWalletId = userWalletId, stakingId = stakingId) + ?: YieldBalance.Error(integrationId = stakingId.integrationId, address = stakingId.address) + } + @Suppress("LongMethod") override suspend fun fetchMultiYieldBalance( userWalletId: UserWalletId, @@ -558,26 +576,7 @@ internal class DefaultStakingRepository( } } - override fun getMultiYieldBalanceUpdatesLegacy( - userWalletId: UserWalletId, - cryptoCurrencies: List, - ): Flow = channelFlow { - stakingBalanceStore.get( - userWalletId = userWalletId, - stakingIds = cryptoCurrencies.mapStakingId(userWalletId), - ) - .onEach { - val balances = YieldBalanceListConverter.convert(it) - send(balances) - } - .launchIn(scope = this + dispatchers.io) - - withContext(dispatchers.io) { - fetchMultiYieldBalance(userWalletId, cryptoCurrencies, refresh = false) - } - } - - override suspend fun getMultiYieldBalanceSync( + override suspend fun getMultiYieldBalanceSyncLegacy( userWalletId: UserWalletId, cryptoCurrencies: List, ): YieldBalanceList = withContext(dispatchers.io) { @@ -588,6 +587,20 @@ internal class DefaultStakingRepository( ?: YieldBalanceList.Error } + override suspend fun getMultiYieldBalanceSync( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): YieldBalanceList { + val stakingIds = cryptoCurrencies.flatMap { + stakingIdFactory.create(userWalletId = userWalletId, currencyId = it.id, network = it.network) + } + + return stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) + ?.filter { it.getStakingId() in stakingIds } + ?.let { YieldBalanceListConverter.convert(value = it.toSet()) } + ?: YieldBalanceList.Error + } + override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { return withContext(dispatchers.io) { stakingBalanceStore.getSyncOrNull(userWalletId) @@ -729,45 +742,13 @@ internal class DefaultStakingRepository( } @Suppress("unused") - private companion object { - const val YIELDS_STORE_KEY = "yields" + companion object { + private const val YIELDS_STORE_KEY = "yields" - const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking" - const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" - const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" - const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" - const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" - const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking" - const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking" - const val TRON_INTEGRATION_ID = "tron-trx-native-staking" - const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking" - const val KAVA_INTEGRATION_ID = "kava-kava-native-staking" - const val NEAR_INTEGRATION_ID = "near-near-native-staking" - const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking" - const val CARDANO_INTEGRATION_ID = "cardano-ada-native-staking" + private const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" - const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + internal val YIELDS_WATITING_TIMEOUT = 15.seconds - val YIELDS_WATITING_TIMEOUT = 15.seconds - - val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79") - - // uncomment items as implementation is ready - val integrationIdMap = mapOf( - Blockchain.TON.run { id + toCoinId() } to TON_INTEGRATION_ID, - Blockchain.Solana.run { id + toCoinId() } to SOLANA_INTEGRATION_ID, - Blockchain.Cosmos.run { id + toCoinId() } to COSMOS_INTEGRATION_ID, - Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID, - Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, - // Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, - Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID, - // Blockchain.Polkadot.run { id + toCoinId() } to POLKADOT_INTEGRATION_ID, - // Blockchain.Avalanche.run { id + toCoinId() } to AVALANCHE_INTEGRATION_ID, - // Blockchain.Cronos.run { id + toCoinId() } to CRONOS_INTEGRATION_ID, - // Blockchain.Kava.run { id + toCoinId() } to KAVA_INTEGRATION_ID, - // Blockchain.Near.run { id + toCoinId() } to NEAR_INTEGRATION_ID, - // Blockchain.Tezos.run { id + toCoinId() } to TEZOS_INTEGRATION_ID, - Blockchain.Cardano.run { id + toCoinId() } to CARDANO_INTEGRATION_ID, - ) + private val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79") } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 43c7003ba5..67dbb87a81 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -9,7 +9,9 @@ import com.tangem.data.staking.DefaultStakingErrorResolver import com.tangem.data.staking.DefaultStakingRepository import com.tangem.data.staking.DefaultStakingTransactionHashRepository import com.tangem.data.staking.converters.error.StakeKitErrorConverter +import com.tangem.data.staking.store.YieldsBalancesStore import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles +import com.tangem.data.staking.utils.StakingIdFactory import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse import com.tangem.datasource.di.NetworkMoshi @@ -41,23 +43,27 @@ internal object StakingDataModule { stakeKitApi: StakeKitApi, stakingYieldsStore: StakingYieldsStore, stakingBalanceStore: StakingBalanceStore, + yieldsBalancesStore: YieldsBalancesStore, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, walletManagersFacade: WalletManagersFacade, getUserWalletUseCase: GetUserWalletUseCase, stakingFeatureToggles: StakingFeatureToggles, + stakingIdFactory: StakingIdFactory, @NetworkMoshi moshi: Moshi, ): StakingRepository { return DefaultStakingRepository( stakeKitApi = stakeKitApi, stakingYieldsStore = stakingYieldsStore, stakingBalanceStore = stakingBalanceStore, + stakingBalanceStoreV2 = yieldsBalancesStore, cacheRegistry = cacheRegistry, dispatchers = dispatchers, walletManagersFacade = walletManagersFacade, getUserWalletUseCase = getUserWalletUseCase, stakingFeatureToggles = stakingFeatureToggles, moshi = moshi, + stakingIdFactory = stakingIdFactory, ) } @@ -89,7 +95,7 @@ internal object StakingDataModule { @Provides @Singleton - internal fun provideStakingErrorResolver( + fun provideStakingErrorResolver( @NetworkMoshi moshi: Moshi, analyticsEventHandler: AnalyticsEventHandler, ): StakingErrorResolver { @@ -102,7 +108,7 @@ internal object StakingDataModule { @Provides @Singleton - internal fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): StakingFeatureToggles { + fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): StakingFeatureToggles { return DefaultStakingFeatureToggles(featureTogglesManager) } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceFetcherModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceFetcherModule.kt new file mode 100644 index 0000000000..9224d037e3 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceFetcherModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.staking.di + +import com.tangem.data.staking.multi.DefaultMultiYieldBalanceFetcher +import com.tangem.data.staking.single.DefaultSingleYieldBalanceFetcher +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.staking.single.SingleYieldBalanceFetcher +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 YieldBalanceFetcherModule { + + @Binds + @Singleton + fun bindSingleYieldBalanceFetcher(impl: DefaultSingleYieldBalanceFetcher): SingleYieldBalanceFetcher + + @Binds + @Singleton + fun bindMultiYieldBalanceFetcher(impl: DefaultMultiYieldBalanceFetcher): MultiYieldBalanceFetcher +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceProducerFactoryModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceProducerFactoryModule.kt new file mode 100644 index 0000000000..5f737af9ee --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceProducerFactoryModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.staking.di + +import com.tangem.data.staking.multi.DefaultMultiYieldBalanceProducer +import com.tangem.data.staking.single.DefaultSingleYieldBalanceProducer +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +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 YieldBalanceProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleYieldBalanceProducerFactory( + impl: DefaultSingleYieldBalanceProducer.Factory, + ): SingleYieldBalanceProducer.Factory + + @Binds + @Singleton + fun bindMultiYieldBalanceProducerFactory( + impl: DefaultMultiYieldBalanceProducer.Factory, + ): MultiYieldBalanceProducer.Factory +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceSupplierModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceSupplierModule.kt new file mode 100644 index 0000000000..599d495859 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/di/YieldBalanceSupplierModule.kt @@ -0,0 +1,56 @@ +package com.tangem.data.staking.di + +import androidx.datastore.core.DataStore +import com.tangem.data.staking.store.DefaultYieldsBalancesStore +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.domain.staking.single.SingleYieldBalanceSupplier +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 YieldBalanceSupplierModule { + + @Provides + @Singleton + fun provideYieldsBalancesStore( + persistenceStore: DataStore>>, + dispatchers: CoroutineDispatcherProvider, + ): YieldsBalancesStore { + return DefaultYieldsBalancesStore( + runtimeStore = RuntimeSharedStore(), + persistenceStore = persistenceStore, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideSingleYieldBalanceSupplier(factory: SingleYieldBalanceProducer.Factory): SingleYieldBalanceSupplier { + return object : SingleYieldBalanceSupplier( + factory = factory, + keyCreator = { + "single_yield_balance_${it.userWalletId.stringValue}_${it.network.id.value}_" + + it.network.derivationPath.value + }, + ) {} + } + + @Provides + @Singleton + fun provideMultiYieldBalanceSupplier(factory: MultiYieldBalanceProducer.Factory): MultiYieldBalanceSupplier { + return object : MultiYieldBalanceSupplier( + factory = factory, + keyCreator = { "multi_yields_balances_${it.userWalletId.stringValue}" }, + ) {} + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/fetcher/CommonYieldBalanceFetcherDelegate.kt b/data/staking/src/main/java/com/tangem/data/staking/fetcher/CommonYieldBalanceFetcherDelegate.kt new file mode 100644 index 0000000000..7204495961 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/fetcher/CommonYieldBalanceFetcherDelegate.kt @@ -0,0 +1,114 @@ +package com.tangem.data.staking.fetcher + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.raise.catch +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import timber.log.Timber + +internal fun commonFetcher( + implementor: YieldBalanceFetcherImplementor, + stakingYieldsStore: StakingYieldsStore, + yieldsBalancesStore: YieldsBalancesStore, + dispatchers: CoroutineDispatcherProvider, +): FlowFetcher { + return CommonYieldBalanceFetcher( + implementor = implementor, + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) +} + +/** + * Common implementation of YieldBalanceFetcher + * + * @param implementor fetcher implementor + * @property stakingYieldsStore staking yields store + * @property yieldsBalancesStore yields balances store + * @property dispatchers dispatchers + */ +private class CommonYieldBalanceFetcher( + private val implementor: YieldBalanceFetcherImplementor, + private val stakingYieldsStore: StakingYieldsStore, + private val yieldsBalancesStore: YieldsBalancesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : FlowFetcher { + + override suspend fun invoke(params: Params): Either { + val stakingIds = getStakingIds(params).getOrElse { + return it.left() + } + + return Either.catchOn(dispatchers.default) { + val requests = prefetch(userWalletId = params.userWalletId, stakingIds = stakingIds) + + implementor.fetch(params = params, stakingIds = stakingIds, requests) + } + .onLeft { yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) } + } + + private suspend fun getStakingIds(params: Params): Either> = either { + val stakingIds = catch( + block = { implementor.createStakingIds(params = params) }, + catch = { raise(it) }, + ) + + ensure(stakingIds.isNotEmpty()) { + val exception = IllegalStateException("Unable to create staking ids for $params: list is empty") + Timber.e(exception) + + raise(exception) + } + + stakingIds + } + + private suspend fun prefetch( + userWalletId: UserWalletId, + stakingIds: Set, + ): List { + yieldsBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + + val yieldDTOs = stakingYieldsStore.getSyncWithTimeout() + + if (yieldDTOs.isNullOrEmpty()) { + val exception = IllegalStateException("No enabled yields for $userWalletId") + Timber.e(exception) + throw exception + } + + val yieldIds = yieldDTOs.mapNotNullTo(destination = hashSetOf(), transform = YieldDTO::id) + + val requests = stakingIds + .filter { stakingId -> yieldIds.any { it == stakingId.integrationId } } + .map(YieldBalanceRequestBodyFactory::create) + + if (requests.isEmpty()) { + val exception = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${stakingIds.joinToString()} + """.trimIndent(), + ) + Timber.d(exception) + throw exception + } + + return requests + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/fetcher/YieldBalanceFetcherImplementor.kt b/data/staking/src/main/java/com/tangem/data/staking/fetcher/YieldBalanceFetcherImplementor.kt new file mode 100644 index 0000000000..be258fc5fe --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/fetcher/YieldBalanceFetcherImplementor.kt @@ -0,0 +1,25 @@ +package com.tangem.data.staking.fetcher + +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID + +/** + * Implementor of internal logic of YieldBalanceFetcher + * +[REDACTED_AUTHOR] + */ +internal interface YieldBalanceFetcherImplementor { + + /** Create set of [StakingID] */ + suspend fun createStakingIds(params: Params): Set + + /** + * Fetch yield balances + * + * @param params params + * @param stakingIds set of [StakingID] + * @param requests requests + */ + suspend fun fetch(params: Params, stakingIds: Set, requests: List) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt new file mode 100644 index 0000000000..7a5e721099 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt @@ -0,0 +1,52 @@ +package com.tangem.data.staking.multi + +import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor +import com.tangem.data.staking.fetcher.commonFetcher +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +/** + * Default implementation of [MultiYieldBalanceFetcher] + * + * @property stakingYieldsStore staking yields store + * @property yieldsBalancesStore yields balances store + * @property stakingIdFactory factory for creating StakingID + * @property stakeKitApi stake kit API + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiYieldBalanceFetcher @Inject constructor( + private val stakingYieldsStore: StakingYieldsStore, + private val yieldsBalancesStore: YieldsBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitApi: StakeKitApi, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiYieldBalanceFetcher, + FlowFetcher by commonFetcher( + implementor = createMultiFetcherImplementor(yieldsBalancesStore, stakingIdFactory, stakeKitApi, dispatchers), + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + +private fun createMultiFetcherImplementor( + yieldsBalancesStore: YieldsBalancesStore, + stakingIdFactory: StakingIdFactory, + stakeKitApi: StakeKitApi, + dispatchers: CoroutineDispatcherProvider, +): YieldBalanceFetcherImplementor { + return MultiYieldBalanceFetcherImplementor( + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = dispatchers, + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..533610817a --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt @@ -0,0 +1,44 @@ +package com.tangem.data.staking.multi + +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEmpty + +/** + * Default implementation of [MultiYieldBalanceProducer] + * + * @property params params + * @property yieldsBalancesStore yields balances store + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiYieldBalanceProducer @AssistedInject constructor( + @Assisted val params: MultiYieldBalanceProducer.Params, + private val yieldsBalancesStore: YieldsBalancesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiYieldBalanceProducer { + + override val fallback: Set + get() = setOf() + + override fun produce(): Flow> { + return yieldsBalancesStore.get(userWalletId = params.userWalletId) + .distinctUntilChanged() + .onEmpty { emit(value = hashSetOf()) } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiYieldBalanceProducer.Factory { + override fun create(params: MultiYieldBalanceProducer.Params): DefaultMultiYieldBalanceProducer + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/MultiYieldBalanceFetcherImplementor.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/MultiYieldBalanceFetcherImplementor.kt new file mode 100644 index 0000000000..8edc3997bc --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/MultiYieldBalanceFetcherImplementor.kt @@ -0,0 +1,58 @@ +package com.tangem.data.staking.multi + +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * Implementor of fetcher for refreshing multiple yield balances + * + * @property yieldsBalancesStore yields balances store + * @property stakingIdFactory factory for creating [StakingID] + * @property stakeKitApi StakeKit API + * @property dispatchers dispatchers + */ +internal class MultiYieldBalanceFetcherImplementor( + private val yieldsBalancesStore: YieldsBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitApi: StakeKitApi, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldBalanceFetcherImplementor { + + override suspend fun createStakingIds(params: YieldBalanceFetcherParams.Multi): Set { + return params.currencyIdWithNetworkMap.flatMapTo(hashSetOf()) { (currencyId, network) -> + stakingIdFactory.create(userWalletId = params.userWalletId, currencyId = currencyId, network = network) + } + } + + override suspend fun fetch( + params: YieldBalanceFetcherParams.Multi, + stakingIds: Set, + requests: List, + ) { + safeApiCall( + call = { + val yieldBalances = withContext(dispatchers.io) { + stakeKitApi.getMultipleYieldBalances(requests).bind() + } + + yieldsBalancesStore.storeActual(userWalletId = params.userWalletId, values = yieldBalances) + }, + onError = { + Timber.e(it, "Unable to fetch yield balances $params") + + yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) + + throw it + }, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcher.kt new file mode 100644 index 0000000000..a59151f771 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcher.kt @@ -0,0 +1,53 @@ +package com.tangem.data.staking.single + +import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor +import com.tangem.data.staking.fetcher.commonFetcher +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.staking.single.SingleYieldBalanceFetcher +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +/** + * Default implementation of [MultiYieldBalanceFetcher] + * + * @property stakingYieldsStore staking yields store + * @property yieldsBalancesStore yields balances store + * @property stakingIdFactory factory for creating StakingID + * @property stakeKitApi stake kit API + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceFetcher @Inject constructor( + private val stakingYieldsStore: StakingYieldsStore, + private val yieldsBalancesStore: YieldsBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitApi: StakeKitApi, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleYieldBalanceFetcher, + FlowFetcher by commonFetcher( + implementor = createSingleFetcherImplementor(yieldsBalancesStore, stakingIdFactory, stakeKitApi, dispatchers), + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + +private fun createSingleFetcherImplementor( + yieldsBalancesStore: YieldsBalancesStore, + stakingIdFactory: StakingIdFactory, + stakeKitApi: StakeKitApi, + dispatchers: CoroutineDispatcherProvider, +): YieldBalanceFetcherImplementor { + return SingleYieldBalanceFetcherImplementor( + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = dispatchers, + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e2bb5cc604 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt @@ -0,0 +1,72 @@ +package com.tangem.data.staking.single + +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleYieldBalanceProducer] + * + * @property params params + * @property multiYieldBalanceSupplier multi yield balance supplier + * @property stakingIdFactory factory for creating [StakingID] + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor( + @Assisted private val params: SingleYieldBalanceProducer.Params, + private val multiYieldBalanceSupplier: MultiYieldBalanceSupplier, + private val stakingIdFactory: StakingIdFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleYieldBalanceProducer { + + override val fallback: YieldBalance by lazy { + YieldBalance.Error( + integrationId = stakingIdFactory.createIntegrationId(currencyId = params.currencyId), + address = null, + ) + } + + override fun produce(): Flow { + return multiYieldBalanceSupplier( + params = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId), + ) + .mapNotNull { + val currentStakingIds = stakingIdFactory.create( + userWalletId = params.userWalletId, + currencyId = params.currencyId, + network = params.network, + ) + + it.firstOrNull { balance -> + val integrationId = balance.integrationId + val address = balance.address + + if (integrationId == null || address == null) return@mapNotNull null + + val balanceStakingId = StakingID(integrationId = integrationId, address = address) + + currentStakingIds.contains(balanceStakingId) + } + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleYieldBalanceProducer.Factory { + override fun create(params: SingleYieldBalanceProducer.Params): DefaultSingleYieldBalanceProducer + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/SingleYieldBalanceFetcherImplementor.kt b/data/staking/src/main/java/com/tangem/data/staking/single/SingleYieldBalanceFetcherImplementor.kt new file mode 100644 index 0000000000..6a47800b12 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/single/SingleYieldBalanceFetcherImplementor.kt @@ -0,0 +1,91 @@ +package com.tangem.data.staking.single + +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * Implementor of fetcher for refreshing single yield balance + * + * @property yieldsBalancesStore yields balances store + * @property stakingIdFactory factory for creating [StakingID] + * @property stakeKitApi StakeKit API + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class SingleYieldBalanceFetcherImplementor( + private val yieldsBalancesStore: YieldsBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitApi: StakeKitApi, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldBalanceFetcherImplementor { + + override suspend fun createStakingIds(params: YieldBalanceFetcherParams.Single): Set { + val dataStakingId = stakingIdFactory.createForDefault( + userWalletId = params.userWalletId, + currencyId = params.currencyId, + network = params.network, + ) ?: return emptySet() + + return setOf( + StakingID(integrationId = dataStakingId.integrationId, address = dataStakingId.address), + ) + } + + override suspend fun fetch( + params: YieldBalanceFetcherParams.Single, + stakingIds: Set, + requests: List, + ) { + fetchInternal( + params = params, + stakingId = stakingIds.first(), + request = requests.first(), + ) + } + + private suspend fun fetchInternal( + params: YieldBalanceFetcherParams.Single, + stakingId: StakingID, + request: YieldBalanceRequestBody, + ) { + safeApiCall( + call = { + val result = withContext(dispatchers.io) { + stakeKitApi.getSingleYieldBalance( + integrationId = stakingId.integrationId, + body = request, + ).bind() + } + + yieldsBalancesStore.storeActual( + userWalletId = params.userWalletId, + values = setOf( + YieldBalanceWrapperDTO( + balances = result, + integrationId = request.integrationId, + addresses = request.addresses, + ), + ), + ) + }, + onError = { + Timber.e(it, "Unable to fetch yield balances $params") + + yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = setOf(stakingId)) + + throw it + }, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt new file mode 100644 index 0000000000..52a301247f --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt @@ -0,0 +1,159 @@ +package com.tangem.data.staking.store + +import androidx.datastore.core.DataStore +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.token.converter.YieldBalanceConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +internal typealias WalletIdWithWrappers = Map> +internal typealias WalletIdWithBalances = Map> + +/** + * Default implementation of [YieldsBalancesStore] + * + * @property runtimeStore runtime store + * @property persistenceStore persistence store + * @param dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultYieldsBalancesStore( + private val runtimeStore: RuntimeSharedStore, + private val persistenceStore: DataStore, + dispatchers: CoroutineDispatcherProvider, +) : YieldsBalancesStore { + + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + init { + scope.launch { + val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch + + runtimeStore.store( + value = cachedStatuses.map { (stringWalletId, wrappers) -> + val key = UserWalletId(stringWalletId) + val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers) + + key to value + } + .toMap(), + ) + } + } + + override fun get(userWalletId: UserWalletId): Flow> { + return runtimeStore.get().mapNotNull { it[userWalletId] } + } + + override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance? { + return runtimeStore.getSyncOrNull() + ?.get(userWalletId) + ?.firstOrNull { stakingId == it.getStakingId() } + } + + override suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set? { + return runtimeStore.getSyncOrNull()?.get(userWalletId) + } + + override suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) { + refresh(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } + + override suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) { + updateInRuntime(userWalletId = userWalletId, stakingIds = stakingIds) { + it.copySealed(source = StatusSource.CACHE) + } + } + + override suspend fun storeActual(userWalletId: UserWalletId, values: Set) { + coroutineScope { + launch { storeInRuntime(userWalletId = userWalletId, values = values) } + launch { storeInPersistence(userWalletId = userWalletId, values = values) } + } + } + + override suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) { + updateInRuntime( + userWalletId = userWalletId, + stakingIds = stakingIds, + ifNotFound = ::createErrorYieldBalance, + update = { it.copySealed(source = StatusSource.ONLY_CACHE) }, + ) + } + + private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set) { + val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values) + + runtimeStore.update(default = emptyMap()) { saved -> + saved.toMutableMap().apply { + this[userWalletId] = saved[userWalletId] + ?.addOrReplace(newBalances) { old, new -> old.getStakingId() == new.getStakingId() } + ?: newBalances + } + } + } + + private suspend fun storeInPersistence(userWalletId: UserWalletId, values: Set) { + persistenceStore.updateData { current -> + current.toMutableMap().apply { + this[userWalletId.stringValue] = current[userWalletId.stringValue] + ?.addOrReplace(items = values) { old, new -> old.getStakingId() == new.getStakingId() } + ?: values + } + } + } + + private suspend fun updateInRuntime( + userWalletId: UserWalletId, + stakingIds: Set, + ifNotFound: (StakingID) -> YieldBalance? = { null }, + update: (YieldBalance) -> YieldBalance, + ) { + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + val portfolioBalances = stored[userWalletId].orEmpty() + + val balances = stakingIds.mapNotNullTo(hashSetOf()) { stakingId -> + val balance = portfolioBalances + .firstOrNull { stakingId == it.getStakingId() } + ?: ifNotFound(stakingId) + ?: return@mapNotNullTo null + + update(balance) + } + + val updatedBalances = portfolioBalances.addOrReplace(items = balances) { old, new -> + old.getStakingId() == new.getStakingId() + } + + put(key = userWalletId, value = updatedBalances) + } + } + } + + private fun createErrorYieldBalance(id: StakingID): YieldBalance { + return YieldBalance.Error(integrationId = id.integrationId, address = id.address) + } + + private fun YieldBalanceWrapperDTO.getStakingId(): StakingID? { + val integrationId = integrationId + val address = addresses.address + + if (integrationId == null || address.isBlank()) return null + + return StakingID(integrationId = integrationId, address = address) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt new file mode 100644 index 0000000000..4549a72ede --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt @@ -0,0 +1,36 @@ +package com.tangem.data.staking.store + +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow + +/** + * Store of [YieldBalance]'s set + * +[REDACTED_AUTHOR] + */ +interface YieldsBalancesStore { + + /** Get flow of [YieldBalance]'s set by [userWalletId] */ + fun get(userWalletId: UserWalletId): Flow> + + /** Get [YieldBalance] by [userWalletId] and [stakingId] synchronously or null */ + suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance? + + /** Get all [YieldBalance] by [userWalletId] synchronously or null */ + suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set? + + /** Refresh balance of [stakingId] by [userWalletId] */ + suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) + + /** Refresh balances of [stakingIds] by [userWalletId] */ + suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) + + /** Store actual [values] by [userWalletId] */ + suspend fun storeActual(userWalletId: UserWalletId, values: Set) + + /** Store error by [userWalletId] and [stakingIds] */ + suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/StakingIdFactory.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/StakingIdFactory.kt new file mode 100644 index 0000000000..9caf2f53fe --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/StakingIdFactory.kt @@ -0,0 +1,89 @@ +package com.tangem.data.staking.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.blockchainsdk.utils.toMigratedCoinId +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId +import javax.inject.Inject + +/** + * Factory of [StakingID] + * + * @property walletManagersFacade wallet manager facade + * +[REDACTED_AUTHOR] + */ +internal class StakingIdFactory @Inject constructor( + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend fun create(userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, network: Network): Set { + val addresses = walletManagersFacade.getAddresses(userWalletId = userWalletId, network = network) + + val integrationId = createIntegrationId(currencyId) ?: return emptySet() + + return addresses.mapTo(hashSetOf()) { address -> + StakingID(integrationId = integrationId, address = address.value) + } + } + + suspend fun createForDefault( + userWalletId: UserWalletId, + currencyId: CryptoCurrency.ID, + network: Network, + ): StakingID? { + val address = walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network) + val integrationId = createIntegrationId(currencyId) + + if (address == null || integrationId == null) return null + + return StakingID(integrationId = integrationId, address = address) + } + + fun createIntegrationId(currencyId: CryptoCurrency.ID): String? { + val integrationKey = with(currencyId) { rawNetworkId.plus(rawCurrencyId) } + return integrationIdMap[integrationKey] + } + + @Suppress("UnusedPrivateMember", "unused") + companion object { + + private const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking" + private const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" + private const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" + private const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" + private const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" + private const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking" + private const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking" + private const val TRON_INTEGRATION_ID = "tron-trx-native-staking" + private const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking" + private const val KAVA_INTEGRATION_ID = "kava-kava-native-staking" + private const val NEAR_INTEGRATION_ID = "near-near-native-staking" + private const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking" + private const val CARDANO_INTEGRATION_ID = "cardano-ada-native-staking" + + // uncomment items as implementation is ready + val integrationIdMap = mapOf( + Blockchain.TON.toDefaultKey() to TON_INTEGRATION_ID, + Blockchain.Solana.toDefaultKey() to SOLANA_INTEGRATION_ID, + Blockchain.Cosmos.toDefaultKey() to COSMOS_INTEGRATION_ID, + Blockchain.Tron.toDefaultKey() to TRON_INTEGRATION_ID, + Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + // Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + Blockchain.BSC.toDefaultKey() to BINANCE_INTEGRATION_ID, + // Blockchain.Polkadot.toDefaultKey() to POLKADOT_INTEGRATION_ID, + // Blockchain.Avalanche.toDefaultKey() to AVALANCHE_INTEGRATION_ID, + // Blockchain.Cronos.toDefaultKey() to CRONOS_INTEGRATION_ID, + // Blockchain.Kava.toDefaultKey() to KAVA_INTEGRATION_ID, + // Blockchain.Near.toDefaultKey() to NEAR_INTEGRATION_ID, + // Blockchain.Tezos.toDefaultKey() to TEZOS_INTEGRATION_ID, + Blockchain.Cardano.toDefaultKey() to CARDANO_INTEGRATION_ID, + ) + + private fun Blockchain.toDefaultKey(): String = id + toCoinId() + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt new file mode 100644 index 0000000000..64d63950a9 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt @@ -0,0 +1,27 @@ +package com.tangem.data.staking.utils + +import com.tangem.datasource.api.stakekit.models.request.Address +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.domain.staking.model.StakingID + +/** + * Factory for creating [YieldBalanceRequestBody] + * +[REDACTED_AUTHOR] + */ +internal object YieldBalanceRequestBodyFactory { + + fun create(stakingID: StakingID): YieldBalanceRequestBody { + return YieldBalanceRequestBody( + addresses = Address( + address = stakingID.address, + additionalAddresses = null, // todo fill additional addresses metadata if needed + explorerUrl = "", // todo fill exporer url [REDACTED_JIRA] + ), + args = YieldBalanceRequestBody.YieldBalanceRequestArgs( + validatorAddresses = listOf(), // todo add validators [REDACTED_JIRA] + ), + integrationId = stakingID.integrationId, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/YieldBalanceExt.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/YieldBalanceExt.kt new file mode 100644 index 0000000000..c04560fbeb --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/YieldBalanceExt.kt @@ -0,0 +1,10 @@ +package com.tangem.data.staking + +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.token.converter.YieldBalanceConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.staking.model.stakekit.YieldBalance + +internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource.CACHE): YieldBalance { + return YieldBalanceConverter(source = source).convert(this) +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt new file mode 100644 index 0000000000..5f57b9d20d --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt @@ -0,0 +1,323 @@ +package com.tangem.data.staking.multi + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.data.staking.MockYieldDTOFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiYieldBalanceFetcherTest { + + private val stakingYieldsStore: StakingYieldsStore = mockk() + private val yieldsBalancesStore: YieldsBalancesStore = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + private val stakeKitApi: StakeKitApi = mockk() + + private val fetcher = DefaultMultiYieldBalanceFetcher( + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `fetch yields balances successfully`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId) + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId) + coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + + val requests = tonAndSolanaIds.map(YieldBalanceRequestBodyFactory::create).sortedBy { it.integrationId } + val result = setOf( + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId), + MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId), + ) + coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns ApiResponse.Success(result) + coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.create(params.userWalletId, ton.id, ton.network) + stakingIdFactory.create(params.userWalletId, solana.id, solana.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getMultipleYieldBalances(requests) + yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) + } + + coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch yields balances failure if stakingIdFactory returns empty list`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns emptySet() + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns emptySet() + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.create(params.userWalletId, ton.id, ton.network) + stakingIdFactory.create(params.userWalletId, solana.id, solana.network) + } + + coVerify(inverse = true) { + yieldsBalancesStore.refresh(any(), any>()) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getMultipleYieldBalances(any()) + yieldsBalancesStore.storeActual(any(), any()) + yieldsBalancesStore.storeError(any(), any()) + } + + val expected = IllegalStateException("Unable to create staking ids for $params: list is empty") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId) + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId) + coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null + coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.create(params.userWalletId, ton.id, ton.network) + stakingIdFactory.create(params.userWalletId, solana.id, solana.network) + yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) + } + + coVerify(inverse = true) { + stakeKitApi.getMultipleYieldBalances(any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId) + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId) + coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList() + coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.create(params.userWalletId, ton.id, ton.network) + stakingIdFactory.create(params.userWalletId, solana.id, solana.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) + } + + coVerify(inverse = true) { + stakeKitApi.getMultipleYieldBalances(any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if yields converting is failed`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId) + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId) + coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs + + val yields = listOf( + MockYieldDTOFactory.create(tonId).copy(id = null), + MockYieldDTOFactory.create(solanaId).copy(id = null), + ) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.create(params.userWalletId, ton.id, ton.network) + stakingIdFactory.create(params.userWalletId, solana.id, solana.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) + } + + coVerify(inverse = true) { + stakeKitApi.getMultipleYieldBalances(any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${setOf(solanaId, tonId).joinToString()} + """.trimIndent(), + ) + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if available yields does not contain ids from params`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId) + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId) + coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs + + val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1"))) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.create(params.userWalletId, ton.id, ton.network) + stakingIdFactory.create(params.userWalletId, solana.id, solana.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) + } + + coVerify(inverse = true) { + stakeKitApi.getMultipleYieldBalances(any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${setOf(solanaId, tonId).joinToString()} + """.trimIndent(), + ) + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId) + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId) + coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + + val requests = setOf(solanaId, tonId).map(YieldBalanceRequestBodyFactory::create) + + @Suppress("UNCHECKED_CAST") + val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) + as ApiResponse> + + coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns errorResponse + coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.create(params.userWalletId, ton.id, ton.network) + stakingIdFactory.create(params.userWalletId, solana.id, solana.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getMultipleYieldBalances(requests) + yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds) + } + + coVerify(inverse = true) { yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) } + + val expected = ApiResponseError.NetworkException + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + private companion object { + val userWalletId = UserWalletId("011") + + val mocks = MockCryptoCurrencyFactory() + + val ton = mocks.createCoin(Blockchain.TON) + val solana = mocks.createCoin(Blockchain.Solana) + + val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + val solanaId = StakingID( + integrationId = "solana-sol-native-multivalidator-staking", + address = "0x1", + ) + + val tonAndSolanaIds = setOf(tonId, solanaId) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt new file mode 100644 index 0000000000..d558475162 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt @@ -0,0 +1,191 @@ +package com.tangem.data.staking.multi + +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.toDomain +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiYieldBalanceProducerTest { + + private val params = MultiYieldBalanceProducer.Params(userWalletId = UserWalletId("011")) + + private val yieldsBalancesStore = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val producer = DefaultMultiYieldBalanceProducer( + params = params, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + + @Test + fun `test that flow is mapped for user wallet id from params`() = runTest { + val balances = setOf( + MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ) + + val networksStatusesFlow = flowOf(balances) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values.first()).isEqualTo(balances) + } + + @Test + fun `test that flow is updated if balances are updated`() = runTest { + val networksStatusesFlow = MutableSharedFlow>(replay = 2) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + // first emit + val balances = setOf( + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(), + ) + + networksStatusesFlow.emit(balances) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first()).isEqualTo(balances) + + // second emit + val updatedWrappers = setOf( + MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ) + + networksStatusesFlow.emit(updatedWrappers) + + val values2 = getEmittedValues(flow = actual) + + val expected = listOf(balances, updatedWrappers) + Truth.assertThat(values2.size).isEqualTo(2) + Truth.assertThat(values2).isEqualTo(expected) + } + + @Test + fun `test that flow is filtered the same balance`() = runTest { + val networksStatusesFlow = MutableSharedFlow>(replay = 2) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + // first emit + val wrappers = setOf( + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(), + ) + + networksStatusesFlow.emit(wrappers) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first()).isEqualTo(wrappers) + + // second emit + networksStatusesFlow.emit(wrappers) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2.first()).isEqualTo(wrappers) + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + val balances = setOf( + MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ) + + val innerFlow = MutableStateFlow(value = false) + val networksStatusesFlow = flow { + if (innerFlow.value) { + emit(balances) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produceWithFallback() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(emptySet())) + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(balances)) + } + + @Test + fun `test that flow is empty`() = runTest { + every { yieldsBalancesStore.get(params.userWalletId) } returns emptyFlow() + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + private companion object { + + val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + val solanaId = StakingID( + integrationId = "solana-sol-native-multivalidator-staking", + address = "0x1", + ) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherTest.kt new file mode 100644 index 0000000000..16e2049744 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherTest.kt @@ -0,0 +1,363 @@ +package com.tangem.data.staking.single + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.data.staking.MockYieldDTOFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO +import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceFetcherTest { + + private val stakingYieldsStore: StakingYieldsStore = mockk() + private val yieldsBalancesStore: YieldsBalancesStore = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + private val stakeKitApi: StakeKitApi = mockk() + + private val fetcher = DefaultSingleYieldBalanceFetcher( + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `fetch yields balances successfully`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + + val request = YieldBalanceRequestBodyFactory.create(tonId) + val result = listOf(createBalanceDTO()) + coEvery { stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) } returns ApiResponse.Success(result) + + val values = result.mapTo(hashSetOf()) { it.toWrapper(request) } + coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = values) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getSingleYieldBalance(integrationId = tonId.integrationId, body = request) + yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = values) + } + + coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch yields balances failure if stakingIdFactory createForDefault returns null`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns null + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(userWalletId = userWalletId, currencyId = ton.id, network = ton.network) + } + + coVerify(inverse = true) { + yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any()) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any()) + } + + val expected = IllegalStateException("Unable to create staking ids for $params: list is empty") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList() + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if yields converting is failed`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId).copy(id = null)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${setOf(tonId).joinToString()} + """.trimIndent(), + ) + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if available yields does not contain ids from params`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1"))) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${setOf(tonId).joinToString()} + """.trimIndent(), + ) + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch yields balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + + val request = YieldBalanceRequestBodyFactory.create(tonId) + + @Suppress("UNCHECKED_CAST") + val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse> + + coEvery { stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) } returns errorResponse + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) + yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(tonId)) + } + + coVerify(inverse = true) { yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) } + + val expected = ApiResponseError.NetworkException + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + private fun createBalanceDTO(): BalanceDTO { + return BalanceDTO( + groupId = "dictas", + type = BalanceDTO.BalanceTypeDTO.REWARDS, + amount = BigDecimal.ONE, + date = null, + pricePerShare = BigDecimal.ONE, + pendingActions = listOf(), + pendingActionConstraints = listOf(), + tokenDTO = TokenDTO( + name = "Casandra Paul", + network = NetworkTypeDTO.POLYGON, + symbol = "vim", + decimals = 3994, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + validatorAddress = null, + validatorAddresses = listOf(), + providerId = null, + ) + } + + private fun BalanceDTO.toWrapper(request: YieldBalanceRequestBody): YieldBalanceWrapperDTO { + return YieldBalanceWrapperDTO( + balances = listOf(this), + integrationId = request.integrationId, + addresses = request.addresses, + ) + } + + private companion object { + val userWalletId = UserWalletId("011") + + val mocks = MockCryptoCurrencyFactory() + + val ton = mocks.createCoin(Blockchain.TON) + + val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt new file mode 100644 index 0000000000..923d406900 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt @@ -0,0 +1,239 @@ +package com.tangem.data.staking.single + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.staking.toDomain +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceProducerTest { + + private val params = SingleYieldBalanceProducer.Params( + userWalletId = UserWalletId(stringValue = "011"), + currencyId = ton.id, + network = ton.network, + ) + + private val multiNetworkStatusSupplier = mockk() + private val stakingIdFactory = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val producer = DefaultSingleYieldBalanceProducer( + params = params, + stakingIdFactory = stakingIdFactory, + multiYieldBalanceSupplier = multiNetworkStatusSupplier, + dispatchers = dispatchers, + ) + + @Test + fun `test that flow is mapped for data from params`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + + val expected = flowOf( + setOf( + balance, + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ), + ) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(balance)) + } + + @Test + fun `test that flow is updated if yield balance is updated`() = runTest { + val expected = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + // first emit + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + expected.emit(value = setOf(balance)) + + val values1 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(balance)) + + // second emit + val updatedStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = tonId.address) + expected.emit(value = setOf(updatedStatus)) + + val values2 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values2.size).isEqualTo(2) + Truth.assertThat(values2).isEqualTo(listOf(balance, updatedStatus)) + } + + @Test + fun `test that flow is filtered the same status`() = runTest { + val expected = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + // first emit + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + expected.emit(value = setOf(balance)) + + val values1 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(balance)) + + // second emit + expected.emit(value = setOf(balance)) + + val values2 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(balance)) + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + + val innerFlow = MutableStateFlow(value = false) + val expected = flow { + if (innerFlow.value) { + emit(setOf(balance)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + every { stakingIdFactory.createIntegrationId(currencyId = params.currencyId) } returns tonId.integrationId + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values1 = getEmittedValues(flow = actual) + + coVerify(inverse = true) { stakingIdFactory.create(any(), any(), any()) } + + Truth.assertThat(values1.size).isEqualTo(1) + val fallbackStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = null) + Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus)) + + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(balance)) + } + + @Test + fun `test if flow doesn't contain network from params`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain() + + val expected = flowOf(setOf(balance)) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values.size).isEqualTo(0) + } + + @Test + fun `test if wallet manager facade returns empty set`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + + val expected = flowOf(setOf(balance)) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns emptySet() + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values.size).isEqualTo(0) + } + + private companion object { + + val mocks = MockCryptoCurrencyFactory() + + val ton = mocks.createCoin(Blockchain.TON) + + val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + val solanaId = StakingID( + integrationId = "solana-sol-native-multivalidator-staking", + address = "0x1", + ) + + val stakingIds = setOf(tonId) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt new file mode 100644 index 0000000000..7900b655e1 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt @@ -0,0 +1,83 @@ +package com.tangem.data.staking.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.staking.toDomain +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class YieldsBalancesStoreGetMethodTest { + + private val runtimeStore = RuntimeSharedStore() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test get if runtime store is empty`() = runTest { + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains empty map`() = runTest { + runtimeStore.store(value = emptyMap()) + + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains portfolio with empty balances`() = runTest { + runtimeStore.store( + value = mapOf(userWalletId to emptySet()), + ) + + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + @Test + fun `test get if runtime store is not empty`() = runTest { + val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + runtimeStore.store( + value = mapOf(userWalletId to setOf(wrapper.toDomain())), + ) + + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(setOf(wrapper.toDomain()))) + } + + private companion object { + + val userWalletId = UserWalletId(stringValue = "011") + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreInitializationTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreInitializationTest.kt new file mode 100644 index 0000000000..13bb51d6e3 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreInitializationTest.kt @@ -0,0 +1,80 @@ +package com.tangem.data.staking.store + +import androidx.datastore.core.DataStore +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.data.staking.toDomain +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class YieldsBalancesStoreInitializationTest { + + @Test + fun `test initialization if cache store is empty`() = runTest { + val runtimeStore = RuntimeSharedStore() + val persistenceStore: DataStore = mockk() + + every { persistenceStore.data } returns emptyFlow() + + DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store contains empty map`() = runTest { + val runtimeStore = RuntimeSharedStore() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `test initialization if cache store is not empty`() = runTest { + val runtimeStore = RuntimeSharedStore() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + persistenceStore.updateData { + it.toMutableMap().apply { + put(userWalletId.stringValue, setOf(wrapper)) + } + } + + DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val expected = mapOf(userWalletId to setOf(wrapper.toDomain())) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected) + } + + private companion object { + val userWalletId = UserWalletId(stringValue = "011") + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreUpdateMethodsTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreUpdateMethodsTest.kt new file mode 100644 index 0000000000..8e6650c1e1 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreUpdateMethodsTest.kt @@ -0,0 +1,178 @@ +package com.tangem.data.staking.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.data.staking.toDomain +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class YieldsBalancesStoreUpdateMethodsTest { + + private val runtimeStore = RuntimeSharedStore() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `refresh the single id if runtime store is empty`() = runTest { + store.refresh(userWalletId = userWalletId, stakingId = stakingId) + + val runtimeExpected = mapOf(userWalletId to emptySet()) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the single id if runtime store contains balance with this id`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance().toDomain(source = StatusSource.ACTUAL) + + runtimeStore.store( + value = mapOf(userWalletId to setOf(balance)), + ) + + store.refresh(userWalletId = userWalletId, stakingId = stakingId) + + val runtimeExpected = mapOf( + userWalletId to setOf( + balance.copySealed(source = StatusSource.CACHE), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the multi ids if runtime store is empty`() = runTest { + store.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + + val runtimeExpected = mapOf(userWalletId to emptySet()) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the multi ids if runtime store contains balance with this id`() = runTest { + val firstBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.first()) + .toDomain(source = StatusSource.ACTUAL) + + val secondBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.last()) + .toDomain(source = StatusSource.ACTUAL) + + runtimeStore.store( + value = mapOf(userWalletId to setOf(firstBalance, secondBalance)), + ) + + store.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + + val runtimeExpected = mapOf( + userWalletId to setOf( + firstBalance.copySealed(source = StatusSource.CACHE), + secondBalance.copySealed(source = StatusSource.CACHE), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store actual if runtime and cache stores contain balance with this id`() = runTest { + val prevWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + runtimeStore.store( + value = mapOf(userWalletId to setOf(prevWrapper.toDomain(source = StatusSource.CACHE))), + ) + + persistenceStore.updateData { + it.toMutableMap().apply { + put(userWalletId.stringValue, setOf(prevWrapper)) + } + } + + val newWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + store.storeActual(userWalletId, setOf(newWrapper)) + + val runtimeExpected = mapOf( + userWalletId to setOf(newWrapper.toDomain(source = StatusSource.ACTUAL)), + ) + + val persistenceExpected = mapOf( + userWalletId.stringValue to setOf(newWrapper), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected) + } + + @Test + fun `store error if runtime store is empty`() = runTest { + store.storeError(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + + val runtimeExpected = mapOf( + userWalletId to setOf( + YieldBalance.Error( + integrationId = stakingId.integrationId, + address = stakingId.address, + ), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store error if runtime store contains balance with this id`() = runTest { + val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId) + + runtimeStore.store( + value = mapOf( + userWalletId to setOf(wrapper.toDomain(source = StatusSource.CACHE)), + ), + ) + + store.storeError(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + + val runtimeExpected = mapOf( + userWalletId to setOf(wrapper.toDomain(source = StatusSource.ONLY_CACHE)), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + private companion object { + + val userWalletId = UserWalletId(stringValue = "011") + + val stakingId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + + val stakingIds = setOf( + stakingId, + StakingID( + integrationId = "solana-sol-native-multivalidator-staking", + address = "0x1", + ), + ) + } +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 6e893fe151..9454dd4a87 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -618,7 +618,7 @@ internal class DefaultCurrenciesRepository( } coroutineScope { - launch { expressServiceLoader.update(userWalletId, tokens) } + launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) } } } @@ -639,7 +639,7 @@ internal class DefaultCurrenciesRepository( skipCache = refresh, block = { coroutineScope { - launch { expressServiceLoader.update(userWalletId, tokens) } + launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) } } }, ) @@ -677,13 +677,13 @@ internal class DefaultCurrenciesRepository( isSortedByBalance = false, ) - private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet { + private fun getUserWallet(userWalletId: UserWalletId): UserWallet { return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { "Unable to find a user wallet with provided ID: $userWalletId" } } - private suspend fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) { + private fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) { val userWallet = getUserWallet(userWalletId) ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected) diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt index 66f0ea8b64..bf7779378a 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt @@ -8,7 +8,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse /** * Adapter to replace unsupported currencies for quotes request if it necessary */ -internal class QuotesUnsupportedCurrenciesIdAdapter { +class QuotesUnsupportedCurrenciesIdAdapter { /** * Replaces unsupported currencies id to it replacements for request diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 07f16b38b8..7c4b516b71 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -14,14 +14,14 @@ import com.tangem.blockchain.blockchains.ton.TonTransactionExtras import com.tangem.blockchain.blockchains.tron.TronTransactionExtras import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.common.extensions.hexToBytes import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository -import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -29,7 +29,6 @@ import kotlinx.coroutines.withContext import timber.log.Timber import java.math.BigDecimal import java.math.BigInteger -import com.tangem.blockchain.blockchains.tron.TransactionType as SdkTransactionType internal class DefaultTransactionRepository( private val walletManagersFacade: WalletManagersFacade, @@ -45,7 +44,6 @@ internal class DefaultTransactionRepository( userWalletId: UserWalletId, network: Network, txExtras: TransactionExtras?, - hash: String?, ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) val walletManager = walletManagersFacade.getOrCreateWalletManager( @@ -54,14 +52,50 @@ internal class DefaultTransactionRepository( derivationPath = network.derivationPath.value, ) ?: error("Wallet manager not found") - return@withContext walletManager.createTransactionDataInternal( + return@withContext walletManager.createTransaction( amount = amount, fee = fee, - memo = memo, destination = destination, + ).copy( + extras = txExtras ?: getMemoExtras(networkId = network.id.value, memo), + ) + } + + override suspend fun createTransferTransaction( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, + ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromId(network.id.value) + + val callData = SmartContractCallDataProviderFactory.getTokenTransferCallData( + destinationAddress = destination, + amount = amount, + blockchain = blockchain, + ) + + val extras = if (amount.type is AmountType.Token && callData != null) { + createTransactionDataExtras( + callData = callData, + network = network, + nonce = null, + gasLimit = null, + ) + } else { + null + } + + return@withContext createTransaction( + amount = amount, + fee = fee, + memo = null, + destination = destination, + userWalletId = userWalletId, network = network, - txExtras = txExtras, - hash = hash, + txExtras = getMemoExtras(networkId = network.id.value, memo = memo) ?: extras, ) } @@ -73,25 +107,16 @@ internal class DefaultTransactionRepository( spenderAddress: String, userWalletId: UserWalletId, network: Network, - hash: String?, ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) - val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = blockchain, - derivationPath = network.derivationPath.value, - ) ?: error("Wallet manager not found") - val approver = walletManager as? Approver ?: error("Cannot cast to Approver") - - val approvalData = approver.getApproveData( - spenderAddress = spenderAddress, - value = approvalAmount, - ) val extras = createTransactionDataExtras( - data = approvalData, + callData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = spenderAddress, + amount = approvalAmount, + blockchain = blockchain, + ), network = network, - transactionType = TransactionType.APPROVE, nonce = null, gasLimit = null, ) @@ -104,7 +129,6 @@ internal class DefaultTransactionRepository( userWalletId = userWalletId, network = network, txExtras = extras, - hash = hash, ) } @@ -115,9 +139,6 @@ internal class DefaultTransactionRepository( destination: String, userWalletId: UserWalletId, network: Network, - isSwap: Boolean, - txExtras: TransactionExtras?, - hash: String?, ): Result = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) val walletManager = walletManagersStore.getSyncOrNull( @@ -129,14 +150,12 @@ internal class DefaultTransactionRepository( val validator = walletManager as? TransactionValidator if (validator != null) { - val transactionData = walletManager.createTransactionDataInternal( + val transactionData = walletManager.createTransaction( amount = amount, fee = fee ?: Fee.Common(amount = amount), - memo = memo, destination = destination, - network = network, - txExtras = txExtras, - hash = hash, + ).copy( + extras = getMemoExtras(networkId = network.id.value, memo = memo), ) validator.validate(transactionData = transactionData) @@ -178,9 +197,8 @@ internal class DefaultTransactionRepository( } override fun createTransactionDataExtras( - data: String, + callData: SmartContractCallData, network: Network, - transactionType: TransactionType, nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras { @@ -189,15 +207,14 @@ internal class DefaultTransactionRepository( return when { blockchain.isEvm() -> { EthereumTransactionExtras( - data = data.hexToBytes(), + callData = callData, gasLimit = gasLimit, nonce = nonce, ) } blockchain == Blockchain.Tron -> { TronTransactionExtras( - data = data.hexToBytes(), - txType = convertToSdkTransactionType(transactionType), + callData = callData, ) } else -> error("Data extras not supported for $blockchain") @@ -226,33 +243,6 @@ internal class DefaultTransactionRepository( ) } - private fun convertToSdkTransactionType(transactionType: TransactionType): SdkTransactionType { - return when (transactionType) { - TransactionType.APPROVE -> SdkTransactionType.APPROVE - } - } - - @Suppress("LongParameterList") - private fun WalletManager.createTransactionDataInternal( - amount: Amount, - fee: Fee, - memo: String?, - destination: String, - network: Network, - txExtras: TransactionExtras?, - hash: String?, - ): TransactionData.Uncompiled { - if (txExtras != null && memo != null) { - // throw error for now to avoid programmers errors when use extras - error("Both txExtras and memo provided, use only one of them") - } - val extras = txExtras ?: getMemoExtras(network.id.value, memo) - return createTransaction(amount, fee, destination).copy( - hash = hash, - extras = extras, - ) - } - @Suppress("CyclomaticComplexMethod") private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { val blockchain = Blockchain.fromId(networkId) @@ -280,4 +270,46 @@ internal class DefaultTransactionRepository( else -> null } } + + override suspend fun prepareForSend( + transactionData: TransactionData, + signer: TransactionSigner, + userWalletId: UserWalletId, + network: Network, + ): Result = withContext(coroutineDispatcherProvider.io) { + val preparer = getPreparer(network, userWalletId) + + when (val prepareForSend = preparer.prepareForSend(transactionData, signer)) { + is com.tangem.blockchain.extensions.Result.Failure -> Result.failure(prepareForSend.error) + is com.tangem.blockchain.extensions.Result.Success -> Result.success(prepareForSend.data) + } + } + + override suspend fun prepareForSendMultiple( + transactionData: List, + signer: TransactionSigner, + userWalletId: UserWalletId, + network: Network, + ): Result> = withContext(coroutineDispatcherProvider.io) { + val preparer = getPreparer(network, userWalletId) + + when (val prepareForSend = preparer.prepareForSendMultiple(transactionData, signer)) { + is com.tangem.blockchain.extensions.Result.Failure -> Result.failure(prepareForSend.error) + is com.tangem.blockchain.extensions.Result.Success -> Result.success(prepareForSend.data) + } + } + + private suspend fun getPreparer(network: Network, userWalletId: UserWalletId): TransactionPreparer { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + val preparer = walletManager as? TransactionPreparer ?: run { + Timber.e("${walletManager?.wallet?.blockchain} does not support TransactionBuilder") + error("Wallet manager does not support TransactionPreparer") + } + return preparer + } } \ No newline at end of file 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 b4bb976ce9..bf72bc979e 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 @@ -1,13 +1,15 @@ package com.tangem.data.visa import com.tangem.data.visa.config.VisaLibLoader -import com.tangem.data.visa.converter.AccessCodeDataConverter -import com.tangem.data.visa.converter.VisaActivationStatusConverter +import com.tangem.data.visa.converter.VisaActivationStatusConverterWithState import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.visa.TangemVisaApi import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest +import com.tangem.datasource.api.visa.models.request.ActivationStatusRequest +import com.tangem.datasource.api.visa.models.request.GetCardWalletAcceptanceRequest +import com.tangem.datasource.api.visa.models.request.GetCustomerWalletAcceptanceRequest import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.domain.visa.exception.RefreshTokenExpiredException @@ -25,9 +27,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( @Assisted private val visaCardId: VisaCardId, private val visaApi: TangemVisaApi, private val dispatcherProvider: CoroutineDispatcherProvider, - private val visaActivationStatusConverter: VisaActivationStatusConverter, private val visaAuthTokenStorage: VisaAuthTokenStorage, - private val accessCodeDataConverter: AccessCodeDataConverter, private val visaAuthRepository: VisaAuthRepository, private val visaLibLoader: VisaLibLoader, ) : VisaActivationRepository { @@ -36,59 +36,38 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( val result = request { val authTokens = checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" } - val accessCodeData = accessCodeDataConverter.convert(authTokens) visaApi.getRemoteActivationStatus( authHeader = authTokens.getAuthHeader(), - customerId = accessCodeData.customerId, - productInstanceId = accessCodeData.productInstanceId, - cardId = visaCardId.cardId, - cardPublicKey = visaCardId.cardPublicKey, + request = ActivationStatusRequest( + cardId = visaCardId.cardId, + cardPublicKey = visaCardId.cardPublicKey, + ), ).getOrThrow() } - visaActivationStatusConverter.convert(result) + VisaActivationStatusConverterWithState.convert(result) } - override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState = - withContext(dispatcherProvider.io) { - val result = request { - val authTokens = - checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" } - val accessCodeData = accessCodeDataConverter.convert(authTokens) - - visaApi.getRemoteActivationStatusLongPoll( - authHeader = authTokens.getAuthHeader(), - customerId = accessCodeData.customerId, - productInstanceId = accessCodeData.productInstanceId, - cardId = visaCardId.cardId, - cardPublicKey = visaCardId.cardPublicKey, - ).getOrThrow() - } - - visaActivationStatusConverter.convert(result) - } - override suspend fun getCardWalletAcceptanceData( request: VisaCardWalletDataToSignRequest, ): VisaDataToSignByCardWallet = withContext(dispatcherProvider.io) { val result = request { val authTokens = checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" } - val accessCodeData = accessCodeDataConverter.convert(authTokens) visaApi.getCardWalletAcceptance( authHeader = authTokens.getAuthHeader(), - customerId = accessCodeData.customerId, - productInstanceId = accessCodeData.productInstanceId, - activationOrderId = request.orderId, - customerWalletAddress = request.customerWalletAddress, + request = GetCardWalletAcceptanceRequest( + customerWalletAddress = request.activationOrderInfo.customerWalletAddress, + cardWalletAddress = request.cardWalletAddress, + ), ).getOrThrow() } VisaDataToSignByCardWallet( request = request, - hashToSign = result.dataForCardWallet.hash, + hashToSign = result.result.hash, ) } @@ -98,20 +77,19 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( val result = request { val authTokens = checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" } - val accessCodeData = accessCodeDataConverter.convert(authTokens) visaApi.getCustomerWalletAcceptance( authHeader = authTokens.getAuthHeader(), - customerId = accessCodeData.customerId, - productInstanceId = accessCodeData.productInstanceId, - activationOrderId = request.orderId, - cardWalletAddress = request.cardWalletAddress, + request = GetCustomerWalletAcceptanceRequest( + cardWalletAddress = request.cardWalletAddress, + customerWalletAddress = request.customerWalletAddress, + ), ).getOrThrow() } VisaDataToSignByCustomerWallet( request = request, - hashToSign = result.dataForCardWallet.hash, + hashToSign = result.result.hash, ) } @@ -120,27 +98,22 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( request { val authTokens = checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" } - val accessCodeData = accessCodeDataConverter.convert(authTokens) visaApi.activateByCardWallet( authHeader = authTokens.getAuthHeader(), body = ActivationByCardWalletRequest( - customerId = accessCodeData.customerId, - productInstanceId = accessCodeData.productInstanceId, - activationOrderId = signedData.dataToSign.request.orderId, - data = ActivationByCardWalletRequest.Data( - cardWallet = ActivationByCardWalletRequest.CardWallet( - address = signedData.cardWalletAddress, - cardWalletConfirmation = null, // for second iteration - deployAcceptanceSignature = signedData.signature, - ), - otp = ActivationByCardWalletRequest.Otp( - rootOtp = signedData.rootOTP, - counter = signedData.otpCounter, - ), + orderId = signedData.dataToSign.request.activationOrderInfo.orderId, + cardWallet = ActivationByCardWalletRequest.CardWallet( + address = signedData.dataToSign.request.cardWalletAddress, + cardWalletConfirmation = null, // for second iteration + ), + deployAcceptanceSignature = signedData.signature, + otp = ActivationByCardWalletRequest.Otp( + rootOtp = signedData.rootOTP, + counter = signedData.otpCounter, ), ), - ) + ).getOrThrow() } } } @@ -150,22 +123,17 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( request { val authTokens = checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" } - val accessCodeData = accessCodeDataConverter.convert(authTokens) visaApi.activateByCustomerWallet( authHeader = authTokens.getAuthHeader(), body = ActivationByCustomerWalletRequest( - customerId = accessCodeData.customerId, - productInstanceId = accessCodeData.productInstanceId, - activationOrderId = signedData.dataToSign.request.orderId, - data = ActivationByCustomerWalletRequest.Data( - customerWallet = ActivationByCustomerWalletRequest.CustomerWallet( - address = signedData.customerWalletAddress, - deployAcceptanceSignature = signedData.signature, - ), + orderId = signedData.dataToSign.request.orderId, + customerWallet = ActivationByCustomerWalletRequest.CustomerWallet( + deployAcceptanceSignature = signedData.signature, + customerWalletAddress = signedData.customerWalletAddress, ), ), - ) + ).getOrThrow() } } } @@ -175,21 +143,16 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( request { val authTokens = checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" } - val accessCodeData = accessCodeDataConverter.convert(authTokens) visaApi.setPinCode( authHeader = authTokens.getAuthHeader(), body = SetPinCodeRequest( - customerId = accessCodeData.customerId, - productInstanceId = accessCodeData.productInstanceId, - activationOrderId = pinCode.activationOrderId, - data = SetPinCodeRequest.Data( - sessionKey = pinCode.sessionId, - iv = pinCode.iv, - encryptedPin = pinCode.encryptedPin, - ), + orderId = pinCode.activationOrderId, + sessionId = pinCode.sessionId, + iv = pinCode.iv, + pin = pinCode.encryptedPin, ), - ) + ).getOrThrow() } } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt index 9e5d4a5122..9e3be2327c 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRepository.kt @@ -1,8 +1,8 @@ package com.tangem.data.visa -import com.tangem.common.extensions.toHexString -import com.tangem.crypto.CryptoUtils +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.visa.TangemVisaAuthApi +import com.tangem.datasource.api.visa.models.request.* import com.tangem.domain.visa.model.VisaAuthChallenge import com.tangem.domain.visa.model.VisaAuthSession import com.tangem.domain.visa.model.VisaAuthSignedChallenge @@ -20,74 +20,80 @@ internal class DefaultVisaAuthRepository @Inject constructor( override suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card = withContext(dispatchers.io) { - // val response = visaAuthApi.generateNonceByCard( - // cardId = cardId, - // cardPublicKey = cardPublicKey, - // ) - // - // VisaAuthChallenge.Card( - // challenge = response.nonce, - // session = VisaAuthSession(response.sessionId), - // ) - + val response = visaAuthApi.generateNonceByCardId( + GenerateNoneByCardIdRequest( + cardId = cardId, + cardPublicKey = cardPublicKey, + ), + ) VisaAuthChallenge.Card( - challenge = CryptoUtils.generateRandomBytes(length = 16).toHexString(), - session = VisaAuthSession("session"), + challenge = response.result.nonce, + session = VisaAuthSession(response.result.sessionId), ) } override suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet = withContext(dispatchers.io) { - // val response = visaAuthApi.generateNonceByWalletAddress( - // customerId = cardId, - // customerWalletAddress = walletPublicKey, - // ) - // - // VisaAuthChallenge.Wallet( - // challenge = response.nonce, - // session = VisaAuthSession(response.sessionId), - // ) + val response = visaAuthApi.generateNonceByCardWallet( + GenerateNoneByCardWalletRequest( + cardWalletAddress = cardWalletAddress, + ), + ) VisaAuthChallenge.Wallet( - challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(), - session = VisaAuthSession("session"), + challenge = response.result.nonce, + session = VisaAuthSession(response.result.sessionId), ) } override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens = withContext(dispatchers.io) { - // val response = when (signedChallenge) { - // is VisaAuthSignedChallenge.ByCardPublicKey -> { - // visaAuthApi.getAccessToken( - // sessionId = signedChallenge.challenge.session.sessionId, - // signature = signedChallenge.signature, - // salt = signedChallenge.salt, - // ) - // } - // is VisaAuthSignedChallenge.ByWallet -> { - // visaAuthApi.getAccessToken( - // sessionId = signedChallenge.challenge.session.sessionId, - // signature = signedChallenge.signature, - // salt = null, - // ) - // } - // } - // - // VisaAuthTokens( - // accessToken = response.accessToken, - // refreshToken = response.refreshToken, - // ) + val response = when (signedChallenge) { + is VisaAuthSignedChallenge.ByCardPublicKey -> { + visaAuthApi.getAccessTokenByCardId( + GetAccessTokenByCardIdRequest( + sessionId = signedChallenge.challenge.session.sessionId, + signature = signedChallenge.signature, + salt = signedChallenge.salt, + ), + ) + } + is VisaAuthSignedChallenge.ByWallet -> { + visaAuthApi.getAccessTokenByCardWallet( + GetAccessTokenByCardWalletRequest( + sessionId = signedChallenge.challenge.session.sessionId, + signature = signedChallenge.signature, + ), + ) + } + } VisaAuthTokens( - accessToken = "accessToken", - refreshToken = VisaAuthTokens.RefreshToken("refreshToken"), + accessToken = response.result.accessToken, + refreshToken = VisaAuthTokens.RefreshToken( + value = response.result.refreshToken, + authType = when (signedChallenge) { + is VisaAuthSignedChallenge.ByCardPublicKey -> VisaAuthTokens.RefreshToken.Type.CardId + is VisaAuthSignedChallenge.ByWallet -> VisaAuthTokens.RefreshToken.Type.CardWallet + }, + ), ) } override suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens = withContext(dispatchers.io) { - // TODO + val response = when (refreshToken.authType) { + VisaAuthTokens.RefreshToken.Type.CardId -> + visaAuthApi.refreshCardIdAccessToken( + RefreshTokenByCardIdRequest(refreshToken = refreshToken.value), + ) + VisaAuthTokens.RefreshToken.Type.CardWallet -> + visaAuthApi.refreshCardIdAccessToken( + RefreshTokenByCardIdRequest(refreshToken = refreshToken.value), + ) + }.getOrThrow() + VisaAuthTokens( - accessToken = "accessToken", - refreshToken = VisaAuthTokens.RefreshToken("new refreshToken"), + accessToken = response.result.accessToken, + refreshToken = refreshToken.copy(value = response.result.refreshToken), ) } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt index 5a33fb6a70..57bddf8b6c 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt @@ -15,18 +15,15 @@ class MockVisaActivationRepository @AssistedInject constructor( override suspend fun getActivationRemoteState(): VisaActivationRemoteState { return VisaActivationRemoteState.CardWalletSignatureRequired( - request = VisaCardWalletDataToSignRequest( + activationOrderInfo = VisaActivationOrderInfo( orderId = "orderId", customerId = "customerId", customerWalletAddress = "customerWalletAddress", + cardWalletAddress = null, ), ) } - override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState { - return VisaActivationRemoteState.PaymentAccountDeploying - } - override suspend fun getCardWalletAcceptanceData( request: VisaCardWalletDataToSignRequest, ): VisaDataToSignByCardWallet { diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt deleted file mode 100644 index d82a09116a..0000000000 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.data.visa.converter - -import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse -import com.tangem.domain.visa.model.VisaActivationRemoteState -import com.tangem.utils.converter.Converter -import javax.inject.Inject - -class VisaActivationStatusConverter @Inject constructor() : - Converter { - - override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState { - // TODO Will be implemented in the future - return VisaActivationRemoteState.Activated - } -} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverterWithState.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverterWithState.kt new file mode 100644 index 0000000000..ff8c166266 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverterWithState.kt @@ -0,0 +1,81 @@ +package com.tangem.data.visa.converter + +import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse +import com.tangem.domain.visa.model.VisaActivationOrderInfo +import com.tangem.domain.visa.model.VisaActivationRemoteState +import com.tangem.utils.converter.Converter +import kotlinx.coroutines.flow.MutableStateFlow + +private const val PIN_CODE_VALIDATION_ERROR = 1000 + +object VisaActivationStatusConverterWithState : + Converter { + + private val lastUpdatedAt = MutableStateFlow(null) + + override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState { + // handle pin code error + // either we entered pin code before with an error (WasError) or after receiving an error (InProgress) + // TODO [REDACTED_TASK_KEY] + val result = value.result + if (result.status == Status.PinCodeRequired.stringValue && result.stepChangeCode == PIN_CODE_VALIDATION_ERROR) { + return if (lastUpdatedAt.value != result.updatedAt) { + lastUpdatedAt.value = result.updatedAt + + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = result.activationOrder!!.convert(), + status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError, + ) + } else { + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = result.activationOrder!!.convert(), + status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress, + ) + } + } + + val status = Status.entries.find { it.stringValue == result.status } ?: error( + "Unknown status: ${result.status}", + ) + + return when (status) { + Status.Activated -> VisaActivationRemoteState.Activated + Status.Failed -> VisaActivationRemoteState.Failed + Status.BlockedForActivation -> VisaActivationRemoteState.BlockedForActivation + Status.CardWalletSignatureRequired -> + VisaActivationRemoteState.CardWalletSignatureRequired( + activationOrderInfo = result.activationOrder!!.convert(), + ) + Status.CustomerWalletSignatureRequired -> + VisaActivationRemoteState.CustomerWalletSignatureRequired( + activationOrderInfo = result.activationOrder!!.convert(), + ) + Status.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying + Status.PinCodeRequired -> VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = result.activationOrder!!.convert(), + status = VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode, + ) + Status.WaitingForActivation -> VisaActivationRemoteState.WaitingForActivationFinishing + } + } + + private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo { + return VisaActivationOrderInfo( + orderId = id, + customerId = customerId, + customerWalletAddress = customerWalletAddress, + cardWalletAddress = cardWalletAddress.takeIf { it.isNotBlank() }, + ) + } + + private enum class Status(val stringValue: String) { + Activated("activated"), + Failed("failed"), + BlockedForActivation("blocked_for_activation"), + CardWalletSignatureRequired("card_wallet_signature_required"), + CustomerWalletSignatureRequired("customer_wallet_signature_required"), + PaymentAccountDeploying("payment_account_deploying"), + PinCodeRequired("pin_code_required"), + WaitingForActivation("waiting_for_activation"), + } +} \ No newline at end of file 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 e1cc459371..a173f8c5ea 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,8 +1,8 @@ package com.tangem.data.visa.di +import com.tangem.data.visa.DefaultVisaActivationRepository import com.tangem.data.visa.DefaultVisaAuthRepository import com.tangem.data.visa.MockVisaRepository -import com.tangem.data.visa.MockVisaActivationRepository import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.repository.VisaAuthRepository import com.tangem.domain.visa.repository.VisaRepository @@ -20,19 +20,19 @@ internal interface VisaDataModule { @Singleton fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository - // @Binds - // @Singleton - // fun bindVisaActivationRepositoryFactory( - // repository: DefaultVisaActivationRepository.Factory, - // ): VisaActivationRepository.Factory - - // Mocked @Binds @Singleton fun bindVisaActivationRepositoryFactory( - repository: MockVisaActivationRepository.Factory, + repository: DefaultVisaActivationRepository.Factory, ): VisaActivationRepository.Factory + // Mocked + // @Binds + // @Singleton + // fun bindVisaActivationRepositoryFactory( + // repository: MockVisaActivationRepository.Factory, + // ): VisaActivationRepository.Factory + // @Binds // fun bindVisaRepository(repository: DefaultVisaRepository): VisaRepository diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt index 538c665358..2c7cae2eb4 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt @@ -6,6 +6,7 @@ import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.visa.TangemVisaAuthApi +import com.tangem.datasource.api.visa.models.request.RefreshTokenByCardWalletRequest import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.visa.exception.RefreshTokenExpiredException @@ -79,16 +80,18 @@ internal class VisaApiRequestMaker @Inject constructor( } private suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens { - val result = visaAuthApi.refreshAccessToken(refreshToken.value).getOrThrow() + val result = visaAuthApi.refreshCardWalletAccessToken( + RefreshTokenByCardWalletRequest(refreshToken = refreshToken.value), + ).getOrThrow() return VisaAuthTokens( - accessToken = result.accessToken, - refreshToken = VisaAuthTokens.RefreshToken(result.refreshToken), + accessToken = result.result.accessToken, + refreshToken = refreshToken.copy(value = result.result.refreshToken), ) } @Throws - private suspend fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens { + private fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens { val userWallet = findVisaUserWallet(userWalletId) val status = userWallet.scanResponse.visaCardActivationStatus ?: error("Visa card activation status not found") @@ -99,7 +102,7 @@ internal class VisaApiRequestMaker @Inject constructor( return (status as? VisaCardActivationStatus.Activated)?.visaAuthTokens ?: error("Visa card is not activated") } - private suspend fun findVisaUserWallet(userWalletId: UserWalletId): UserWallet { + private fun findVisaUserWallet(userWalletId: UserWalletId): UserWallet { val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { "No user wallet found: $userWalletId" } diff --git a/data/wallet-connect/build.gradle.kts b/data/wallet-connect/build.gradle.kts index 159348516a..d03ae4e940 100644 --- a/data/wallet-connect/build.gradle.kts +++ b/data/wallet-connect/build.gradle.kts @@ -14,6 +14,8 @@ dependencies { /* Project - Domain */ implementation(projects.domain.walletConnect) implementation(projects.domain.walletConnect.models) + implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens) @@ -35,6 +37,7 @@ dependencies { /* Tangem libraries */ implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) /* Reown - WalletConnect */ implementation(deps.reownCore) { @@ -47,4 +50,13 @@ dependencies { /* Other */ implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) + implementation(projects.domain.blockaid) + implementation(projects.domain.blockaid.models) + + /* Tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.turbine) } \ 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 edab0d12af..3c2b20e4f5 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 @@ -5,27 +5,26 @@ import com.squareup.moshi.Moshi import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.walletconnect.DefaultWalletConnectRepository import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase -import com.tangem.data.walletconnect.model.NamespaceKey import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase +import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.request.DefaultWcRequestService -import com.tangem.data.walletconnect.request.WcMethodHandler +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter import com.tangem.data.walletconnect.respond.DefaultWcRespondService +import com.tangem.data.walletconnect.respond.WcRespondService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.walletconnect.WalletConnectStore +import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.model.WcMethod 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.request.WcRequestService -import com.tangem.domain.walletconnect.respond.WcRespondService import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase import com.tangem.domain.walletmanager.WalletManagersFacade @@ -58,12 +57,12 @@ internal object WalletConnectDataModule { application: Application, sessionsManager: DefaultWcSessionsManager, networkService: DefaultWcRequestService, - wcPairFlow: DefaultWcPairUseCase, + pairSdkDelegate: WcPairSdkDelegate, ): WcInitializeUseCase = DefaultWcInitializeUseCase( application = application, sessionsManager = sessionsManager, networkService = networkService, - wcPairFlow = wcPairFlow, + pairSdkDelegate = pairSdkDelegate, ) @Provides @@ -72,16 +71,24 @@ internal object WalletConnectDataModule { sessionsManager: WcSessionsManager, associateNetworksDelegate: AssociateNetworksDelegate, caipNamespaceDelegate: CaipNamespaceDelegate, + sdkDelegate: WcPairSdkDelegate, + blockAidVerifier: BlockAidVerifier, ): DefaultWcPairUseCase = DefaultWcPairUseCase( sessionsManager = sessionsManager, associateNetworksDelegate = associateNetworksDelegate, caipNamespaceDelegate = caipNamespaceDelegate, + sdkDelegate = sdkDelegate, + blockAidVerifier = blockAidVerifier, ) @Provides @Singleton fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default + @Provides + @Singleton + fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate() + @Provides @Singleton fun defaultWcSessionsManager( @@ -107,38 +114,47 @@ internal object WalletConnectDataModule { @Provides @Singleton fun defaultWcRequestService( - sessionsManager: WcSessionsManager, - respondService: WcRespondService, dispatchers: CoroutineDispatcherProvider, diHelperBox: DiHelperBox, ): DefaultWcRequestService { val scope = CoroutineScope(SupervisorJob() + dispatchers.io) return DefaultWcRequestService( - sessionsManager = sessionsManager, - respondService = respondService, - requestAdapters = diHelperBox.handlers, + requestConverters = diHelperBox.handlers, scope = scope, ) } - @Provides - @Singleton - fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default - @Provides @Singleton fun wcRespondService(): WcRespondService = DefaultWcRespondService() @Provides @Singleton - fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork( + fun wcEthNetwork( + @SdkMoshi moshi: Moshi, + excludedBlockchains: ExcludedBlockchains, + sessionsManager: WcSessionsManager, + factories: WcEthNetwork.Factories, + ): WcEthNetwork = WcEthNetwork( moshi = moshi, - respondService = respondService, + excludedBlockchains = excludedBlockchains, + sessionsManager = sessionsManager, + factories = factories, ) @Provides @Singleton - fun wcSolanaNetwork(): WcSolanaNetwork = WcSolanaNetwork() + fun wcSolanaNetwork( + @SdkMoshi moshi: Moshi, + excludedBlockchains: ExcludedBlockchains, + sessionsManager: WcSessionsManager, + factories: WcSolanaNetwork.Factories, + ): WcSolanaNetwork = WcSolanaNetwork( + moshi = moshi, + sessionsManager = sessionsManager, + factories = factories, + excludedBlockchains = excludedBlockchains, + ) @Provides @Singleton @@ -156,12 +172,10 @@ internal object WalletConnectDataModule { diHelperBox: DiHelperBox, getWallets: GetWalletsUseCase, currenciesRepository: CurrenciesRepository, - excludedBlockchains: ExcludedBlockchains, ): AssociateNetworksDelegate = AssociateNetworksDelegate( namespaceConverters = diHelperBox.converters, getWallets = getWallets, currenciesRepository = currenciesRepository, - excludedBlockchains = excludedBlockchains, ) @Provides @@ -169,15 +183,16 @@ internal object WalletConnectDataModule { fun diHelperBox(ethNetwork: WcEthNetwork, solanaNetwork: WcSolanaNetwork) = DiHelperBox( handlers = setOf( ethNetwork, + solanaNetwork, + ), + converters = setOf( + ethNetwork, + solanaNetwork, ), - converters = buildMap { - ethNetwork.namespaceKey to ethNetwork - solanaNetwork.namespaceKey to solanaNetwork - }, ) internal class DiHelperBox( - val converters: Map, - val handlers: Set>, + val converters: Set, + val handlers: Set, ) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt index 6330859a84..91db31c6c8 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt @@ -6,7 +6,7 @@ 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.data.walletconnect.pair.DefaultWcPairUseCase +import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.request.DefaultWcRequestService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager import com.tangem.data.walletconnect.utils.WcSdkObserver @@ -17,13 +17,13 @@ internal class DefaultWcInitializeUseCase( private val application: Application, private val sessionsManager: DefaultWcSessionsManager, private val networkService: DefaultWcRequestService, - private val wcPairFlow: DefaultWcPairUseCase, + private val pairSdkDelegate: WcPairSdkDelegate, ) : WcInitializeUseCase { private val wcSdkObservers = mutableSetOf( sessionsManager, networkService, - wcPairFlow, + pairSdkDelegate, ) override fun init(projectId: String) { diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthMessageSignUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthMessageSignUseCase.kt new file mode 100644 index 0000000000..bbbd62d234 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthMessageSignUseCase.kt @@ -0,0 +1,104 @@ +package com.tangem.data.walletconnect.network.ethereum + +import arrow.core.left +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak +import com.tangem.blockchain.common.HEX_PREFIX +import com.tangem.blockchain.extensions.isAscii +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toDecompressedPublicKey +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +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.domain.transaction.usecase.SignUseCase +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMessageSignUseCase +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +internal class DefaultWcEthMessageSignUseCase @AssistedInject constructor( + override val respondService: WcRespondService, + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcEthMethod.MessageSign, + private val walletManagersFacade: WalletManagersFacade, + private val signUseCase: SignUseCase, +) : BaseWcSignUseCase(), + WcEthMessageSignUseCase { + + override suspend fun SignCollector.onSign( + state: WcSignState, + ) { + val hashToSign = LegacySdkHelper.createMessageData(state.signModel.rawMsg) + val userWallet = session.wallet + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) + ?: return + + val signedHash = signUseCase(hashToSign, userWallet, network) + .onLeft { emit(state.toResult(it.left())) } + .getOrNull() ?: return + + val respond = EthereumUtils.prepareSignedMessageData( + signedHash = signedHash, + hashToSign = hashToSign, + publicKey = walletManager.wallet.publicKey.blockchainKey.toDecompressedPublicKey(), + ) + + val wcRespondResult = respondService.respond(rawSdkRequest, respond) + emit(state.toResult(wcRespondResult)) + } + + override fun invoke(): Flow> = flow { + val model = WcEthMessageSignUseCase.SignModel( + rawMsg = method.message, + account = method.account, + humanMsg = LegacySdkHelper.hexToAscii(method.message).orEmpty(), + ) + emitAll(delegate(model)) + } + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcEthMethod.MessageSign): DefaultWcEthMessageSignUseCase + } +} + +object LegacySdkHelper { + private const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n" + + fun createMessageData(message: String): ByteArray { + val messageData = try { + message.removePrefix(HEX_PREFIX).hexToBytes() + } catch (exception: Exception) { + message.asciiToHex()?.hexToBytes() ?: byteArrayOf() + } + + val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray() + return (prefixData + messageData).toKeccak() + } + + fun hexToAscii(hex: String): String? { + return try { + hex.removePrefix(HEX_PREFIX).hexToBytes().map { + val char = it.toInt().toChar() + if (char.isAscii()) char else return null + }.joinToString("") + } catch (exception: Exception) { + return null + } + } + + private fun String.asciiToHex(): String? { + return map { + if (!it.isAscii()) return null + Integer.toHexString(it.code) + }.joinToString("") + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSendTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSendTransactionUseCase.kt new file mode 100644 index 0000000000..c923e955fd --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSendTransactionUseCase.kt @@ -0,0 +1,60 @@ +package com.tangem.data.walletconnect.network.ethereum + +import arrow.core.left +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.formatHex +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +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.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSendTransactionUseCase +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthTransaction +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +internal class DefaultWcEthSendTransactionUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcEthMethod.SendTransaction, + override val respondService: WcRespondService, + private val sendTransaction: SendTransactionUseCase, +) : BaseWcSignUseCase(), + WcEthSendTransactionUseCase { + + private val converter = EthTransactionParamsConverter(context) + + override suspend fun SignCollector.onSign(state: WcSignState) { + val hash = sendTransaction(state.signModel.transactionData, wallet, network) + .onLeft { error -> + val sendError = IllegalArgumentException(error.toString()) // todo(wc) use domain error + emit(state.toResult(sendError.left())) + } + .getOrNull() ?: return + val respondResult = respondService.respond(rawSdkRequest, hash.formatHex()) + emit(state.toResult(respondResult)) + } + + override fun updateFee(fee: Fee) { + middleAction(fee) + } + + override fun invoke(): Flow> = flow { + val ethTransaction = converter.convert(method.transaction) ?: return@flow + emitAll(delegate.invoke(ethTransaction)) + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcEthMethod.SendTransaction, + ): DefaultWcEthSendTransactionUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSignTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSignTransactionUseCase.kt new file mode 100644 index 0000000000..70b1c7b3a3 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSignTransactionUseCase.kt @@ -0,0 +1,69 @@ +package com.tangem.data.walletconnect.network.ethereum + +import arrow.core.left +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.formatHex +import com.tangem.common.extensions.toHexString +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +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.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSignTransactionUseCase +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthTransaction +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +internal class DefaultWcEthSignTransactionUseCase @AssistedInject constructor( + override val respondService: WcRespondService, + private val prepareForSend: PrepareForSendUseCase, + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcEthMethod.SignTransaction, +) : BaseWcSignUseCase(), + WcEthSignTransactionUseCase { + + private val converter = EthTransactionParamsConverter(context) + + override suspend fun SignCollector.onSign(state: WcSignState) { + val hash = prepareForSend(state.signModel.transactionData, wallet, network) + .map { it.toHexString().formatHex() } + .onLeft { error -> + emit(state.toResult(error.left())) + } + .getOrNull() + ?: return + val respondResult = respondService.respond(rawSdkRequest, hash) + emit(state.toResult(respondResult)) + } + + override suspend fun FlowCollector.onMiddleAction(signModel: WcEthTransaction, fee: Fee) { + val newState = signModel + .copy(transactionData = signModel.transactionData.copy(fee = fee)) + emit(newState) + } + + override fun updateFee(fee: Fee) { + middleAction(fee) + } + + override fun invoke(): Flow> = flow { + val ethTransaction = converter.convert(method.transaction) ?: return@flow + emitAll(delegate.invoke(ethTransaction)) + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcEthMethod.SignTransaction, + ): DefaultWcEthSignTransactionUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/EthTransactionParamsConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/EthTransactionParamsConverter.kt new file mode 100644 index 0000000000..ac63abc8f8 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/EthTransactionParamsConverter.kt @@ -0,0 +1,25 @@ +package com.tangem.data.walletconnect.network.ethereum + +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.domain.walletconnect.model.WcEthTransactionParams +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthTransaction +import com.tangem.utils.converter.Converter + +internal class EthTransactionParamsConverter( + private val context: WcMethodUseCaseContext, +) : Converter { + + override fun convert(value: WcEthTransactionParams): WcEthTransaction? { + val dAppFee = WcEthTxHelper.getDAppFee(context.network, value) + val transactionData = WcEthTxHelper.createTransactionData( + dAppFee = dAppFee, + network = context.network, + txParams = value, + ) + transactionData ?: return null + return WcEthTransaction( + dAppFee = dAppFee, + transactionData = transactionData, + ) + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt index 69407681e0..171bb54185 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt @@ -2,57 +2,76 @@ package com.tangem.data.walletconnect.network.ethereum import com.squareup.moshi.Moshi import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.walletconnect.model.CAIP2 import com.tangem.data.walletconnect.model.NamespaceKey -import com.tangem.data.walletconnect.request.WcMethodHandler +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext import com.tangem.data.walletconnect.utils.WcNamespaceConverter -import com.tangem.domain.walletconnect.model.WcMethod -import com.tangem.domain.walletconnect.model.WcRequest -import com.tangem.domain.walletconnect.respond.WcRespondService -import com.tangem.domain.walletconnect.usecase.WcUseCase -import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider -import com.tangem.domain.walletconnect.usecase.ethereum.EthPersonalSignUseCase -import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod -import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod.SignMessage -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.model.WcEthTransactionParams +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.wallets.models.UserWallet +import jakarta.inject.Inject -internal class WcEthNetwork( +internal class WcEthNetwork constructor( private val moshi: Moshi, - private val respondService: WcRespondService, -) : WcMethodHandler, WcUseCasesFlowProvider, WcNamespaceConverter { - - private val _useCases: Channel = Channel(Channel.BUFFERED) - override val useCases = _useCases.receiveAsFlow() + private val excludedBlockchains: ExcludedBlockchains, + private val sessionsManager: WcSessionsManager, + private val factories: Factories, +) : WcRequestToUseCaseConverter, WcNamespaceConverter { override val namespaceKey: NamespaceKey = NamespaceKey("eip155") - override fun canHandle(methodName: String): Boolean { - return Name.entries.find { it.raw == methodName } != null + override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? { + val methodKey = request.request.method + val name = Name.entries.find { it.raw == methodKey } ?: return null + val method: WcEthMethod = name.toMethod(request) ?: return null + val session = sessionsManager.findSessionByTopic(request.topic) ?: return null + val network = toNetwork(request.chainId.orEmpty(), session.wallet) ?: return null + val context = WcMethodUseCaseContext(session = session, rawSdkRequest = request, network = network) + + return when (method) { + is WcEthMethod.MessageSign -> factories.messageSign.create(context, method) + is WcEthMethod.SendTransaction -> factories.sendTransaction.create(context, method) + is WcEthMethod.SignTransaction -> factories.signTransaction.create(context, method) + } } - override fun deserialize(methodName: String, params: String): WcEthMethod? { - val name = Name.entries.find { it.raw == methodName } ?: return null - return when (name) { - Name.Sign -> TODO() - Name.PersonalSign -> WcMethodHandler.fromJson(params, moshi) + private fun Name.toMethod(request: WcSdkSessionRequest): WcEthMethod? { + val rawParams = request.request.params + return when (this) { + Name.EthSign, + Name.PersonalSign, + -> moshi.fromJson>(rawParams)?.let { list -> + val accountIndex = if (this == Name.EthSign) 0 else 1 + val messageIndex = if (this == Name.EthSign) 1 else 0 + val account = list.getOrNull(accountIndex) ?: return@let null + val message = list.getOrNull(messageIndex) ?: return@let null + WcEthMethod.MessageSign(account = account, message = message) + } Name.SignTypeData -> TODO() Name.SignTypeDataV4 -> TODO() - Name.SignTransaction -> TODO() - Name.SendTransaction -> TODO() + Name.SignTransaction, + Name.SendTransaction, + -> moshi.fromJson>(rawParams) + ?.firstOrNull() + ?.let { + if (this == Name.SignTransaction) { + WcEthMethod.SignTransaction(transaction = it) + } else { + WcEthMethod.SendTransaction(transaction = it) + } + } } } - override fun handle(wcRequest: WcRequest) { - wcRequest as WcRequest - val useCase = when (wcRequest.method) { - is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest, respondService) - } - _useCases.trySend(useCase) - } - enum class Name(val raw: String) { - Sign("eth_sign"), + EthSign("eth_sign"), PersonalSign("personal_sign"), SignTypeData("eth_signTypedData"), SignTypeDataV4("eth_signTypedData_v4"), @@ -60,13 +79,18 @@ internal class WcEthNetwork( SendTransaction("eth_sendTransaction"), } + override fun toNetwork(chainId: String, wallet: UserWallet): Network? { + return toNetwork(chainId, wallet, excludedBlockchains) + } + override fun toBlockchain(chainId: CAIP2): Blockchain? { if (chainId.namespace != namespaceKey.key) return null val ethChainId = chainId.reference.toIntOrNull() ?: return null return Blockchain.fromChainId(ethChainId) } - override fun toCAIP2(blockchain: Blockchain): CAIP2? { + override fun toCAIP2(network: Network): CAIP2? { + val blockchain = Blockchain.fromId(network.id.value) if (!blockchain.isEvm()) return null val chainId = blockchain.getChainId() ?: return null return CAIP2( @@ -74,4 +98,10 @@ internal class WcEthNetwork( reference = chainId.toString(), ) } + + internal class Factories @Inject constructor( + val messageSign: DefaultWcEthMessageSignUseCase.Factory, + val sendTransaction: DefaultWcEthSendTransactionUseCase.Factory, + val signTransaction: DefaultWcEthSignTransactionUseCase.Factory, + ) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt new file mode 100644 index 0000000000..bc1f5e26c5 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthTxHelper.kt @@ -0,0 +1,58 @@ +package com.tangem.data.walletconnect.network.ethereum + +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.HEX_PREFIX +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.hexToBigDecimal +import com.tangem.common.extensions.hexToBytes +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcEthTransactionParams +import java.math.BigDecimal + +internal object WcEthTxHelper { + private val MANTLE_FEE_ESTIMATE_MULTIPLIER = BigDecimal("1.8") + + fun getDAppFee(network: Network, txParams: WcEthTransactionParams): Fee.Ethereum.Legacy? { + val gasLimit = txParams.gas?.hexToBigDecimal() ?: return null + val gasPrice = txParams.gasPrice?.hexToBigDecimal() ?: return null + + val blockchain = Blockchain.fromId(network.id.value) + + var feeDecimal = (gasLimit * gasPrice) + .movePointLeft(blockchain.decimals()) + if (blockchain == Blockchain.Mantle) { + feeDecimal = feeDecimal.multiply(MANTLE_FEE_ESTIMATE_MULTIPLIER) + } + + val feeAmount = Amount(feeDecimal, blockchain) + return Fee.Ethereum.Legacy(feeAmount, gasLimit.toBigInteger(), gasPrice.toBigInteger()) + } + + fun createTransactionData( + dAppFee: Fee.Ethereum.Legacy?, + network: Network, + txParams: WcEthTransactionParams, + ): TransactionData.Uncompiled? { + val destinationAddress = txParams.to ?: return null + val blockchain = Blockchain.fromId(network.id.value) + val value = (txParams.value ?: "0") + .hexToBigDecimal() + .movePointLeft(blockchain.decimals()) + + val callData = CompiledSmartContractCallData(txParams.data.removePrefix(HEX_PREFIX).hexToBytes()) + return TransactionData.Uncompiled( + amount = Amount(value, blockchain), + fee = dAppFee, + sourceAddress = txParams.from, + destinationAddress = destinationAddress, + extras = EthereumTransactionExtras( + callData = callData, + nonce = txParams.nonce?.hexToBigDecimal()?.toBigInteger(), + ), + ) + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/DefaultWcSolanaSignAllTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/DefaultWcSolanaSignAllTransactionUseCase.kt new file mode 100644 index 0000000000..4724192a80 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/DefaultWcSolanaSignAllTransactionUseCase.kt @@ -0,0 +1,78 @@ +package com.tangem.data.walletconnect.network.solana + +import arrow.core.left +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.encodeBase64 +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +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.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.walletconnect.model.WcSolanaMethod +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.solana.WcSolanaSignAllTransactionUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import okio.ByteString.Companion.decodeBase64 +import org.json.JSONArray +import org.json.JSONObject + +internal class DefaultWcSolanaSignAllTransactionUseCase @AssistedInject constructor( + override val respondService: WcRespondService, + private val prepareForSend: PrepareForSendUseCase, + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcSolanaMethod.SignAllTransaction, +) : BaseWcSignUseCase>(), + WcSolanaSignAllTransactionUseCase { + + override suspend fun SignCollector>.onSign( + state: WcSignState>, + ) { + val hash = prepareForSend.invoke(transactionData = state.signModel, userWallet = wallet, network = network) + .onLeft { error -> + emit(state.toResult(error.left())) + } + .getOrNull() + ?: return + val respond = getSolanaResultTxHashesString(hash) + val respondResult = respondService.respond(rawSdkRequest, respond) + emit(state.toResult(respondResult)) + } + + override fun invoke(): Flow>> { + val transactionData = method.transaction + .map { it.decodeBase64()?.toByteArray() ?: ByteArray(0) } + .map { TransactionData.Compiled(value = TransactionData.Compiled.Data.Bytes(it)) } + + return delegate.invoke(transactionData) + } + + /** + * 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() + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcSolanaMethod.SignAllTransaction, + ): DefaultWcSolanaSignAllTransactionUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/DefaultWcSolanaSignTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/DefaultWcSolanaSignTransactionUseCase.kt new file mode 100644 index 0000000000..4a497421ff --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/DefaultWcSolanaSignTransactionUseCase.kt @@ -0,0 +1,57 @@ +package com.tangem.data.walletconnect.network.solana + +import arrow.core.left +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.encodeBase58 +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +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.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.walletconnect.model.WcSolanaMethod +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.solana.WcSolanaSignTransactionUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import okio.ByteString.Companion.decodeBase64 + +internal class DefaultWcSolanaSignTransactionUseCase @AssistedInject constructor( + override val respondService: WcRespondService, + private val prepareForSend: PrepareForSendUseCase, + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcSolanaMethod.SignTransaction, +) : BaseWcSignUseCase(), + WcSolanaSignTransactionUseCase { + + override suspend fun SignCollector.onSign(state: WcSignState) { + val hash = prepareForSend.invoke(transactionData = state.signModel, userWallet = wallet, network = network) + .onLeft { error -> + emit(state.toResult(error.left())) + } + .getOrNull() + ?: return + val respond = "{ signature: \"${hash.encodeBase58()}\" }" + val respondResult = respondService.respond(rawSdkRequest, respond) + emit(state.toResult(respondResult)) + } + + override fun invoke(): Flow> { + val data = method.transaction.decodeBase64()?.toByteArray() ?: ByteArray(0) + + val transactionData = TransactionData.Compiled( + value = TransactionData.Compiled.Data.Bytes(data), + ) + return delegate.invoke(transactionData) + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcSolanaMethod.SignTransaction, + ): DefaultWcSolanaSignTransactionUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt new file mode 100644 index 0000000000..fe252ca79c --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt @@ -0,0 +1,19 @@ +package com.tangem.data.walletconnect.network.solana + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class WcSolanaSignMessageRequest( + @Json(name = "pubkey") + val publicKey: String, + + @Json(name = "message") + val message: String, +) + +@JsonClass(generateAdapter = true) +internal data class WcSolanaSignTransactionRequest( + @Json(name = "transaction") + val transaction: String, +) \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt index bda63dde43..d376a58417 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt @@ -1,13 +1,45 @@ package com.tangem.data.walletconnect.network.solana +import com.squareup.moshi.Moshi import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.walletconnect.model.CAIP2 import com.tangem.data.walletconnect.model.NamespaceKey +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext import com.tangem.data.walletconnect.utils.WcNamespaceConverter +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcSolanaMethod +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.wallets.models.UserWallet +import jakarta.inject.Inject + +internal class WcSolanaNetwork constructor( + private val moshi: Moshi, + private val sessionsManager: WcSessionsManager, + private val factories: Factories, + private val excludedBlockchains: ExcludedBlockchains, +) : WcNamespaceConverter, WcRequestToUseCaseConverter { -internal class WcSolanaNetwork : WcNamespaceConverter { override val namespaceKey: NamespaceKey = NamespaceKey("solana") + override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? { + val methodKey = request.request.method + val name = Name.entries.find { it.raw == methodKey } ?: return null + val method: WcSolanaMethod = name.toMethod(request) ?: return null + val session = sessionsManager.findSessionByTopic(request.topic) ?: return null + val network = toNetwork(request.chainId.orEmpty(), session.wallet) ?: return null + val context = WcMethodUseCaseContext(session = session, rawSdkRequest = request, network = network) + return when (method) { + is WcSolanaMethod.SignMessage -> TODO() + is WcSolanaMethod.SignTransaction -> factories.signTransaction.create(context, method) + is WcSolanaMethod.SignAllTransaction -> factories.signAllTransaction.create(context, method) + } + } + override fun toBlockchain(chainId: CAIP2): Blockchain? { if (chainId.namespace != namespaceKey.key) return null return when (chainId.reference) { @@ -17,7 +49,12 @@ internal class WcSolanaNetwork : WcNamespaceConverter { } } - override fun toCAIP2(blockchain: Blockchain): CAIP2? { + override fun toNetwork(chainId: String, wallet: UserWallet): Network? { + return toNetwork(chainId, wallet, excludedBlockchains) + } + + override fun toCAIP2(network: Network): CAIP2? { + val blockchain = Blockchain.fromId(network.id.value) val chainId = when (blockchain) { Blockchain.Solana -> MAINNET_CHAIN_ID Blockchain.SolanaTestnet -> TESTNET_CHAIN_ID @@ -30,6 +67,32 @@ internal class WcSolanaNetwork : WcNamespaceConverter { ) } + private fun Name.toMethod(request: WcSdkSessionRequest): WcSolanaMethod? { + val rawParams = request.request.params + return when (this) { + Name.SignMessage -> moshi.fromJson(rawParams)?.let { request -> + WcSolanaMethod.SignMessage(pubKey = request.publicKey, message = request.message) + } + Name.SignTransaction -> moshi.fromJson(rawParams)?.let { request -> + WcSolanaMethod.SignTransaction(request.transaction) + } + Name.SendAllTransaction -> moshi.fromJson>(rawParams)?.let { list -> + WcSolanaMethod.SignAllTransaction(list) + } + } + } + + private enum class Name(val raw: String) { + SignMessage("solana_signMessage"), + SignTransaction("solana_signTransaction"), + SendAllTransaction("solana_signAllTransactions"), + } + + internal class Factories @Inject constructor( + val signTransaction: DefaultWcSolanaSignTransactionUseCase.Factory, + val signAllTransaction: DefaultWcSolanaSignAllTransactionUseCase.Factory, + ) + companion object { private const val MAINNET_CHAIN_ID = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" private const val TESTNET_CHAIN_ID = "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" 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 e24b2641ac..316902dfea 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 @@ -1,92 +1,72 @@ package com.tangem.data.walletconnect.pair import com.reown.walletkit.client.Wallet -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.data.common.currency.getNetwork -import com.tangem.data.walletconnect.model.CAIP2 -import com.tangem.data.walletconnect.model.NamespaceKey import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.model.WcNetwork import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetWalletsUseCase internal class AssociateNetworksDelegate constructor( - private val namespaceConverters: Map, + private val namespaceConverters: Set, private val getWallets: GetWalletsUseCase, private val currenciesRepository: CurrenciesRepository, - private val excludedBlockchains: ExcludedBlockchains, ) { @Throws(WcPairError.UnsupportedNetworks::class) - suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map { + suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map { val userWallets = getWallets.invokeSync().filter { it.isMultiCurrency } - val requiredNamespaces: Set = sessionProposal.requiredNamespaces.setOfChainId() - val optionalNamespaces: Set = sessionProposal.optionalNamespaces.setOfChainId() + val requiredNamespaces: Set = sessionProposal.requiredNamespaces.setOfChainId() + val optionalNamespaces: Set = sessionProposal.optionalNamespaces.setOfChainId() - return userWallets.associate { wallet -> - wallet.walletId to mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) - } + return userWallets + .associateWith { wallet -> mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) } } private suspend fun mapNetworksForWallet( wallet: UserWallet, - requiredNamespaces: Set, - optionalNamespaces: Set, + requiredNamespaces: Set, + optionalNamespaces: Set, ): ProposalNetwork { val walletNetworks = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(wallet.walletId) .filterIsInstance() .map { it.network } - val unknownRequired = mutableSetOf() - val missingRequired = mutableSetOf() - val required = mutableSetOf() - val available = mutableSetOf() - val notAdded = mutableSetOf() - - fun CAIP2.toBlockchain() = namespaceConverters[NamespaceKey(this.namespace)]?.toBlockchain(this) - fun Blockchain.toNetwork() = getNetwork( - blockchain = this, - extraDerivationPath = null, - scanResponse = wallet.scanResponse, - excludedBlockchains = excludedBlockchains, - ) + val unknownRequired = mutableSetOf() + val missingRequired = mutableSetOf() + val required = mutableSetOf() + val available = mutableSetOf() + val notAdded = mutableSetOf() requiredNamespaces.forEach { chainId -> - val blockchain = chainId.toBlockchain() - if (blockchain == null) { - unknownRequired.add(WcNetwork.Unknown(missingNetworkName(chainId))) - return@forEach - } - val wcNetwork = blockchain.toNetwork() + val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) } if (wcNetwork == null) { - unknownRequired.add(WcNetwork.Unknown(missingNetworkName(blockchain))) + unknownRequired.add(missingNetworkName(chainId)) return@forEach } val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id } if (walletNetwork == null) { - missingRequired.add(WcNetwork.Supported(wcNetwork)) + missingRequired.add(wcNetwork) } else { - required.add(WcNetwork.Supported(walletNetwork)) + required.add(walletNetwork) } } optionalNamespaces.forEach { chainId -> - val wcNetwork = chainId.toBlockchain()?.toNetwork() ?: return@forEach + val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) } + ?: return@forEach val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id } if (walletNetwork == null) { - available.add(WcNetwork.Supported(wcNetwork)) + available.add(wcNetwork) } else { - notAdded.add(WcNetwork.Supported(walletNetwork)) + notAdded.add(walletNetwork) } } if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired) return ProposalNetwork( - walletId = wallet.walletId, + wallet = wallet, missingRequired = missingRequired, required = required, available = available, @@ -94,10 +74,8 @@ internal class AssociateNetworksDelegate constructor( ) } - private fun Map.setOfChainId(): Set = - this.values.flatMap { proposal -> proposal.chains ?: listOf() } - .mapNotNull { rawChainId -> CAIP2.fromRaw(rawChainId) }.toSet() + private fun Map.setOfChainId(): Set = + this.values.flatMap { proposal -> proposal.chains ?: listOf() }.toSet() - private fun missingNetworkName(chainId: CAIP2): String = chainId.namespace.replaceFirstChar(Char::titlecase) - private fun missingNetworkName(blockchain: Blockchain): String = blockchain.getCoinName() + private fun missingNetworkName(chainId: String): String = chainId.replaceFirstChar(Char::titlecase) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt index 98e5e6be32..597bdecefb 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt @@ -1,32 +1,27 @@ package com.tangem.data.walletconnect.pair import com.reown.walletkit.client.Wallet -import com.tangem.blockchain.common.Blockchain import com.tangem.data.walletconnect.model.CAIP10 -import com.tangem.data.walletconnect.model.NamespaceKey import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.domain.tokens.model.Network import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet internal class CaipNamespaceDelegate constructor( - private val namespaceConverters: Map, + private val namespaceConverters: Set, private val walletManagersFacade: WalletManagersFacade, ) { suspend fun associate( sessionProposal: Wallet.Model.SessionProposal, - userWalletId: UserWalletId, + userWallet: UserWallet, networks: List, ): Map { - val converters = namespaceConverters.values - val result = mutableMapOf() networks.map { network -> - val blockchain = Blockchain.fromId(network.id.value) - val address = walletManagersFacade.getDefaultAddress(userWalletId, network) - val chainId = converters.firstOrNull { it.toCAIP2(blockchain) != null }?.toCAIP2(blockchain) + val address = walletManagersFacade.getDefaultAddress(userWallet.walletId, network) + val chainId = namespaceConverters.firstNotNullOfOrNull { it.toCAIP2(network) } requireNotNull(chainId) requireNotNull(address) CAIP10(chainId = chainId, accountAddress = address) 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 65f7b110ee..107fb0289f 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 @@ -1,12 +1,14 @@ package com.tangem.data.walletconnect.pair import arrow.core.Either +import arrow.core.getOrElse import arrow.core.left import arrow.core.right +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData import com.reown.walletkit.client.Wallet -import com.reown.walletkit.client.WalletKit -import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionConverter +import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionApprove @@ -15,15 +17,13 @@ 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 com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber -import kotlin.coroutines.resume val unsupportedDApps = listOf("dYdX", "dYdX v4", "Apex Pro", "The Sandbox") @@ -31,27 +31,19 @@ internal class DefaultWcPairUseCase( private val sessionsManager: WcSessionsManager, private val associateNetworksDelegate: AssociateNetworksDelegate, private val caipNamespaceDelegate: CaipNamespaceDelegate, -) : WcPairUseCase, WcSdkObserver { + private val sdkDelegate: WcPairSdkDelegate, + private val blockAidVerifier: BlockAidVerifier, +) : WcPairUseCase { private val onCallTerminalAction = Channel() - private val onSessionProposal = - Channel>(Channel.BUFFERED) - private val onSessionSettleResponse = Channel(Channel.BUFFERED) override fun pairFlow(uri: String, source: WcPairUseCase.Source): Flow { return flow { emit(WcPairState.Loading) - // call sdk.pair and wait result, finish flow on error - walletKitPair(uri).onLeft { throwable -> - emit(WcPairState.Error(WcPairError.Unknown(throwable.localizedMessage.orEmpty()))) - return@flow - } - // wait for sdk onSessionProposal callback - val (sdkSessionProposal, verifyContext) = onSessionProposal.receiveAsFlow() - .first { (sessionProposal, verifyContext) -> - true // todo(wc) check verifyContext? compare uri? - } + val sdkSessionProposal = sdkDelegate.pair(uri) + .onLeft { emit(WcPairState.Error(it)) } + .getOrNull() ?: return@flow // check unsupported dApps, just local constant for now, finish if unsupported if (sdkSessionProposal.name in unsupportedDApps) { @@ -62,44 +54,35 @@ internal class DefaultWcPairUseCase( } val proposalState = buildProposalState(sdkSessionProposal) - .fold(ifLeft = { WcPairState.Error(it) }, ifRight = { it }) + .onLeft { emit(WcPairState.Error(it)) } + .getOrNull() ?: return@flow emit(proposalState) // wait first terminal action and continue WC pair flow val terminalAction = onCallTerminalAction.receiveAsFlow().first() val sessionForApprove: WcSessionApprove? = when (terminalAction) { is TerminalAction.Approve -> terminalAction.sessionForApprove - TerminalAction.Reject -> { - // non suspending WalletKit.rejectSession call - rejectSession(sdkSessionProposal.proposerPublicKey) - null - } + TerminalAction.Reject -> null } // finish flow if rejected above - sessionForApprove ?: return@flow + if (sessionForApprove == null) { + sdkDelegate.rejectSession(sdkSessionProposal.proposerPublicKey) + return@flow + } // start flow of approving in wc sdk emit(WcPairState.Approving.Loading(sessionForApprove)) - // call sdk approve and wait result val either = walletKitApproveSession( sessionForApprove = sessionForApprove, sdkSessionProposal = sdkSessionProposal, - ).fold( - ifLeft = { WcPairError.ExternalApprovalError(it.localizedMessage.orEmpty()).left() }, - ifRight = { - when (val settledSession = onSessionSettleResponse.receiveAsFlow().first()) { - is Wallet.Model.SettledSessionResponse.Error -> WcPairError.ExternalApprovalError( - settledSession.errorMessage, - ).left() - - is Wallet.Model.SettledSessionResponse.Result -> { - val newSession = settledSession.session.toDomain(sessionForApprove.walletId) - sessionsManager.saveSession(sessionForApprove.walletId, newSession) - newSession.right() - } - } - }, - ) + ).map { settledSession -> + val newSession = settledSession.session.toDomain( + wallet = sessionForApprove.wallet, + securityStatus = proposalState.dAppSession.securityStatus, + ) + sessionsManager.saveSession(newSession) + newSession + } emit(WcPairState.Approving.Result(sessionForApprove, either)) } } @@ -112,83 +95,30 @@ internal class DefaultWcPairUseCase( onCallTerminalAction.trySend(TerminalAction.Reject) } - 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") - onSessionProposal.trySend(sessionProposal to verifyContext) - } - - override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { - // Triggered when wallet receives the session settlement response from Dapp - Timber.i("onSessionSettleResponse: $settleSessionResponse") - onSessionSettleResponse.trySend(settleSessionResponse) - } - - private suspend fun walletKitPair(uri: String): Either = - suspendCancellableCoroutine { continuation -> - WalletKit.pair( - params = Wallet.Params.Pair(uri), - onSuccess = { - Timber.i("Paired successfully: $it") - continuation.resume(Unit.right()) - }, - onError = { - Timber.e("Error while pairing: $it") - continuation.resume(it.throwable.left()) - }, - ) - } - private suspend fun walletKitApproveSession( sessionForApprove: WcSessionApprove, sdkSessionProposal: Wallet.Model.SessionProposal, - ): Either { + ): Either { val namespaces = caipNamespaceDelegate.associate( sdkSessionProposal, - sessionForApprove.walletId, - sessionForApprove.network.map { it.network }, + sessionForApprove.wallet, + sessionForApprove.network, ) val sessionApprove = Wallet.Params.SessionApprove( proposerPublicKey = sdkSessionProposal.proposerPublicKey, namespaces = namespaces, ) - return suspendCancellableCoroutine { continuation -> - WalletKit.approveSession( - params = sessionApprove, - onSuccess = { - Timber.i("Approved successfully: $it") - continuation.resume(Unit.right()) - }, - onError = { - Timber.e("Error while approving: $it") - continuation.resume(it.throwable.left()) - }, - ) - } - } - - private fun rejectSession(proposerPublicKey: String) { - WalletKit.rejectSession( - params = Wallet.Params.SessionReject( - proposerPublicKey = proposerPublicKey, - reason = "", - ), - onSuccess = { - Timber.i("Rejected successfully: $it") - }, - onError = { - Timber.e("Error while rejecting: $it") - }, - ) + return sdkDelegate.approve(sessionApprove) } private suspend fun buildProposalState( sessionProposal: Wallet.Model.SessionProposal, ): Either = runCatching { val proposalNetwork = associateNetworksDelegate.associate(sessionProposal) + val verificationInfo = blockAidVerifier.verifyDApp(DAppData(sessionProposal.url)).getOrElse { + Timber.e("Failed to verify DApp: ${it.localizedMessage}") + CheckDAppResult.FAILED_TO_VERIFY + } val appMetaData = WcAppMetaData( name = sessionProposal.name, description = sessionProposal.description, @@ -199,7 +129,7 @@ internal class DefaultWcPairUseCase( val dAppSession = WcSessionProposal( dAppMetaData = appMetaData, proposalNetwork = proposalNetwork, - securityStatus = Any(), + securityStatus = verificationInfo, ) WcPairState.Proposal(dAppSession) }.fold(onSuccess = { it.right() }, onFailure = { @@ -209,10 +139,13 @@ internal class DefaultWcPairUseCase( } },) - private fun Wallet.Model.Session.toDomain(walletId: UserWalletId): WcSession = WcSession( - userWalletId = walletId, - sdkModel = WcSdkSessionConverter.convert(this), - ) + private fun Wallet.Model.Session.toDomain(wallet: UserWallet, securityStatus: CheckDAppResult): WcSession { + return WcSession( + wallet = wallet, + sdkModel = WcSdkSessionConverter.convert(this), + securityStatus = securityStatus, + ) + } private sealed interface TerminalAction { data class Approve(val sessionForApprove: WcSessionApprove) : 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 new file mode 100644 index 0000000000..1c11137607 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -0,0 +1,98 @@ +package com.tangem.data.walletconnect.pair + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit +import com.tangem.data.walletconnect.utils.WcSdkObserver +import com.tangem.domain.walletconnect.model.WcPairError +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +internal class WcPairSdkDelegate : WcSdkObserver { + + private val onSessionProposal = Channel() + private val onSessionSettleResponse = Channel() + + suspend fun pair(url: String): Either = coroutineScope { + suspend fun proposalCallback() = onSessionProposal + .receiveAsFlow() + .filter { proposal -> proposal.url == url } + .first() + + val pairCall = async { sdkPair(url) } + val proposal = async { proposalCallback() } + pairCall.await().map { proposal.await() } + } + + suspend fun approve( + sessionApprove: Wallet.Params.SessionApprove, + ): Either = coroutineScope { + suspend fun approveCallback() = onSessionSettleResponse + .receiveAsFlow() + .first() + + val approveCall = async { sdkApprove(sessionApprove) } + val approveCallback = async { approveCallback() } + approveCall.await() + .onLeft { return@coroutineScope it.left() } + when (val result = approveCallback.await()) { + is Wallet.Model.SettledSessionResponse.Result -> result.right() + is Wallet.Model.SettledSessionResponse.Error -> + WcPairError.ExternalApprovalError(result.errorMessage).left() + } + } + + fun rejectSession(proposerPublicKey: String) { + WalletKit.rejectSession( + params = Wallet.Params.SessionReject( + proposerPublicKey = proposerPublicKey, + reason = "", + ), + onSuccess = {}, + onError = {}, + ) + } + + override fun onSessionProposal( + sessionProposal: Wallet.Model.SessionProposal, + verifyContext: Wallet.Model.VerifyContext, + ) { + // Triggered when wallet receives the session proposal sent by a Dapp + onSessionProposal.trySend(sessionProposal) + } + + override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { + // Triggered when wallet receives the session settlement response from Dapp + onSessionSettleResponse.trySend(settleSessionResponse) + } + + private suspend fun sdkApprove(sessionApprove: Wallet.Params.SessionApprove): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.approveSession( + params = sessionApprove, + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.toPairError()) }, + ) + } + } + + private suspend fun sdkPair(uri: String): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.pair( + params = Wallet.Params.Pair(uri), + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.toPairError()) }, + ) + } + } + + private fun Throwable.toPairError() = WcPairError.ExternalApprovalError(this.localizedMessage.orEmpty()).left() +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt index 99f06b1f7a..d4b9fdbf33 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt @@ -3,23 +3,20 @@ package com.tangem.data.walletconnect.request import com.reown.walletkit.client.Wallet import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter -import com.tangem.domain.walletconnect.model.WcMethod -import com.tangem.domain.walletconnect.model.WcRequest -import com.tangem.domain.walletconnect.repository.WcSessionsManager -import com.tangem.domain.walletconnect.request.WcRequestService -import com.tangem.domain.walletconnect.respond.WcRespondService +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch internal class DefaultWcRequestService( - private val sessionsManager: WcSessionsManager, - private val respondService: WcRespondService, - private val requestAdapters: Set>, + private val requestConverters: Set, private val scope: CoroutineScope, -) : WcRequestService, WcSdkObserver { +) : WcSdkObserver, WcUseCasesFlowProvider { - override val requests: MutableSharedFlow> = MutableSharedFlow() + private val _useCases: Channel = Channel(Channel.BUFFERED) + override val useCases = _useCases.receiveAsFlow() override fun onSessionRequest( sessionRequest: Wallet.Model.SessionRequest, @@ -27,21 +24,10 @@ internal class DefaultWcRequestService( ) { // Triggered when a Dapp sends SessionRequest to sign a transaction or a message val sr = WcSdkSessionRequestConverter.convert(sessionRequest) - val method = sr.request.method - val params = sr.request.params scope.launch { - val session = sessionsManager.findSessionByTopic(sr.topic) - val handler: WcMethodHandler? = requestAdapters.firstOrNull { it.canHandle(method) } - - val deserialized: WcMethod? = handler?.deserialize(method, params) - if (handler == null || deserialized == null || session == null) { - respondService.rejectRequest(sr, "UnsupportedMethod") // todo(wc) use our domain error - return@launch - } - - val wcRequest = WcRequest(sr, session, deserialized) - handler.handle(wcRequest) - requests.emit(wcRequest) + val useCase = requestConverters.firstNotNullOfOrNull { it.toUseCase(sr) } + ?: return@launch // todo(wc) handle unsupported + _useCases.trySend(useCase) } } } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcMethodHandler.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcMethodHandler.kt deleted file mode 100644 index b31b707d58..0000000000 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcMethodHandler.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.data.walletconnect.request - -import com.squareup.moshi.Moshi -import com.squareup.moshi.adapter -import com.tangem.domain.walletconnect.model.WcMethod -import com.tangem.domain.walletconnect.model.WcRequest - -interface WcMethodHandler { - fun canHandle(methodName: String): Boolean - fun deserialize(methodName: String, params: String): M? - fun handle(wcRequest: WcRequest) - - companion object { - @OptIn(ExperimentalStdlibApi::class) - inline fun fromJson(params: String, moshi: Moshi): T? = - runCatching { moshi.adapter().fromJsonValue(params) }.getOrNull() - } -} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcRequestToUseCaseConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcRequestToUseCaseConverter.kt new file mode 100644 index 0000000000..624440c0df --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcRequestToUseCaseConverter.kt @@ -0,0 +1,16 @@ +package com.tangem.data.walletconnect.request + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase + +interface WcRequestToUseCaseConverter { + suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? + + companion object { + @OptIn(ExperimentalStdlibApi::class) + inline fun Moshi.fromJson(params: String): T? = + runCatching { this.adapter().fromJson(params) }.getOrNull() + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt index 7381e9b548..025e9834b2 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt @@ -6,7 +6,6 @@ import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest -import com.tangem.domain.walletconnect.respond.WcRespondService import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -50,4 +49,19 @@ internal class DefaultWcRespondService : WcRespondService { }, ) } + + override fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String) { + WalletKit.respondSessionRequest( + params = Wallet.Params.SessionRequestResponse( + sessionTopic = request.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( + id = request.request.id, + code = 0, + message = message, + ), + ), + onSuccess = {}, + onError = {}, + ) + } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt similarity index 72% rename from domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt rename to data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt index c74ce0e017..0eb8948d59 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.walletconnect.respond +package com.tangem.data.walletconnect.respond import arrow.core.Either import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest @@ -6,4 +6,5 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest interface WcRespondService { suspend fun respond(request: WcSdkSessionRequest, response: String): Either suspend fun rejectRequest(request: WcSdkSessionRequest, message: String = ""): Either + fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String = "") } \ No newline at end of file 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 90ccec710d..d23e135111 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,6 +3,7 @@ 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.data.walletconnect.utils.WcSdkObserver @@ -12,7 +13,7 @@ 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.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.CoroutineScope @@ -25,7 +26,7 @@ import timber.log.Timber import kotlin.coroutines.resume import kotlin.time.Duration.Companion.seconds -internal class DefaultWcSessionsManager constructor( +internal class DefaultWcSessionsManager( private val store: WalletConnectStore, private val legacyStore: WalletConnectSessionsRepository, private val getWallets: GetWalletsUseCase, @@ -36,20 +37,22 @@ internal class DefaultWcSessionsManager constructor( private val onSessionDelete = Channel(capacity = Channel.BUFFERED) private val oneTimeMigration = MutableStateFlow(true) - override val sessions: Flow>> - get() = store.sessions - .transform { inStore -> + 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) + val someMigrated = migrateLegacyStore(inStore, inSdk, wallets) if (someMigrated) return@transform // ignore emit, wait next one } - val inSdk: List = WalletKit.getListOfActiveSessions() - val associatedSessions: List = associateWithSdk(inSdk, inStore) + val associatedSessions: List = associate(inSdk, inStore, wallets) val someRemove = removeUnknownSessions(inStore, associatedSessions) if (someRemove) return@transform // ignore emit, wait next one - emit(associatedSessions.groupBy { it.userWalletId }) + emit(associatedSessions.groupBy { it.wallet }) } + .distinctUntilChanged() .flowOn(dispatchers.io) override fun onWcSdkInit() { @@ -57,11 +60,11 @@ internal class DefaultWcSessionsManager constructor( listenOnSessionDelete() } - override suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) { - store.saveSession(WcSessionDTO(session.sdkModel.topic, session.userWalletId)) + override suspend fun saveSession(session: WcSession) { + store.saveSession(WcSessionDTO(session.sdkModel.topic, session.wallet.walletId, session.securityStatus)) } - override suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either { + override suspend fun removeSession(session: WcSession): Either { val topic = session.sdkModel.topic val sdkCall = sdkDisconnectSession(topic) sdkCall.onLeft { return it.left() } @@ -80,9 +83,17 @@ internal class DefaultWcSessionsManager constructor( } override suspend fun findSessionByTopic(topic: String): WcSession? = withContext(dispatchers.io) { - val storedSessions = store.findSessionByTopic(topic) ?: return@withContext null + val storedSession = sessions.firstOrNull() + ?.values?.flatten() + ?.firstOrNull { it.sdkModel.topic == topic } + ?: return@withContext null val sdkSession = WalletKit.getActiveSessionByTopic(topic) ?: return@withContext null - WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession)) + val wallet = storedSession.wallet + WcSession( + wallet = wallet, + sdkModel = WcSdkSessionConverter.convert(sdkSession), + securityStatus = storedSession.securityStatus, + ) } override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { @@ -90,28 +101,41 @@ internal class DefaultWcSessionsManager constructor( onSessionDelete.trySend(sessionDelete) } - private suspend fun migrateLegacyStore(inNewStoreSessions: Set): Boolean { - val walletIds = getWallets.invokeSync().mapTo(mutableSetOf()) { it.walletId } - val inLegacyStoreSessions = walletIds + 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).map { WcSessionDTO(it.topic, walletId) }) } + flow { + emit( + legacyStore.loadSessions(walletId.stringValue).map { + WcSessionDTO(it.topic, walletId, 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 = inLegacyStoreSessions.subtract(inNewStoreSessions) + val mustSaveInNewStore = inLegacyStore.subtract(inNewStore) if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore) return mustSaveInNewStore.isNotEmpty() } - private fun associateWithSdk( - sdkSessions: List, - storeSessions: Set, + private fun associate( + inSdk: List, + inStore: Set, + wallets: List, ): List { - val wcSessions = sdkSessions.mapNotNull { sdkSession -> - val storedSessions = storeSessions.find { it.topic == sdkSession.topic } - ?: return@mapNotNull null - WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession)) + val wcSessions = inStore.mapNotNull { session -> + val wallet = wallets.find { it.walletId == session.walletId } ?: return@mapNotNull null + val sdkSession = inSdk.find { it.topic == session.topic } ?: return@mapNotNull null + WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession), session.securityStatus) } return wcSessions } @@ -122,9 +146,6 @@ internal class DefaultWcSessionsManager constructor( val haveSomeUnknown = unknownStoredSessions.isNotEmpty() if (haveSomeUnknown) { - unknownStoredSessions.forEach { unknown -> - legacyStore.removeSession(unknown.walletId.stringValue, unknown.topic) - } store.removeSessions(unknownStoredSessions.toSet()) } return haveSomeUnknown diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt new file mode 100644 index 0000000000..8967ae0706 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt @@ -0,0 +1,65 @@ +package com.tangem.data.walletconnect.sign + +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase +import com.tangem.domain.wallets.models.UserWallet +import kotlinx.coroutines.flow.FlowCollector + +internal abstract class BaseWcSignUseCase : + WcMethodUseCase, + WcSignUseCase, + FinalActionCollector, + MiddleActionCollector { + + abstract val respondService: WcRespondService + + abstract val context: WcMethodUseCaseContext + override val network: Network get() = context.network + override val session: WcSession get() = context.session + override val rawSdkRequest: WcSdkSessionRequest get() = context.rawSdkRequest + val wallet: UserWallet get() = session.wallet + + protected val delegate by lazy { + WcSignUseCaseDelegate( + finalActionCollector = this, + middleActionCollector = this, + ) + } + + override suspend fun onCancel(currentState: WcSignState) { + defaultReject() + } + + override fun sign() = delegate.sign() + override fun cancel() = delegate.cancel() + protected fun middleAction(action: MiddleAction) = delegate.middleAction(action) + + protected fun defaultReject() { + respondService.rejectRequestNonBlock(rawSdkRequest) + } +} + +internal interface MiddleActionCollector { + + suspend fun FlowCollector.onMiddleAction(signModel: SignModel, middleAction: MiddleAction) {} +} + +internal interface FinalActionCollector { + + suspend fun SignCollector.onSign(state: WcSignState) {} + + suspend fun onCancel(state: WcSignState) {} +} + +internal class WcMethodUseCaseContext( + val session: WcSession, + val rawSdkRequest: WcSdkSessionRequest, + val network: Network, +) + +internal typealias SignCollector = FlowCollector> \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt new file mode 100644 index 0000000000..f77af76789 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.data.walletconnect.sign + +import arrow.core.Either +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep + +object SignStateConverter { + + internal fun preSign(signModel: M) = WcSignState(signModel, WcSignStep.PreSign) + internal fun signing(signModel: M) = WcSignState(signModel, WcSignStep.Signing) + internal fun result(result: Either, signModel: M) = + WcSignState(signModel, WcSignStep.Result(result)) + + internal fun WcSignState.toPreSign(signModel: M = this.signModel) = copy( + signModel = signModel, + domainStep = WcSignStep.PreSign, + ) + + internal fun WcSignState.toSigning(signModel: M = this.signModel) = copy( + domainStep = WcSignStep.Signing, + signModel = signModel, + ) + + internal fun WcSignState.toResult(result: Either, signModel: M = this.signModel) = copy( + domainStep = WcSignStep.Result(result), + signModel = signModel, + ) +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt new file mode 100644 index 0000000000..89d8bc1097 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt @@ -0,0 +1,91 @@ +package com.tangem.data.walletconnect.sign + +import arrow.core.left +import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +internal class WcSignUseCaseDelegate( + private val finalActionCollector: FinalActionCollector, + private val middleActionCollector: MiddleActionCollector, +) : FinalActionCollector by finalActionCollector, + MiddleActionCollector by middleActionCollector { + + private val middleActionsChannel = Channel() + private val finalActionsChannel = Channel() + + fun cancel() { + finalActionsChannel.trySend(Action.Cancel) + } + + fun sign() { + finalActionsChannel.trySend(Action.Sign) + } + + fun middleAction(action: MiddleAction) { + middleActionsChannel.trySend(action) + } + + operator fun invoke(initModel: SignModel) = channelFlow { + val state = MutableStateFlow(WcSignState(initModel, WcSignStep.PreSign)) + + state + .onEach { newState -> channel.send(newState) } + .launchIn(this) + + fun listenMiddle() = middleActionsChannel.receiveAsFlow() + .buffer() + .transform { middleActions -> this.onMiddleAction(state.value.signModel, middleActions) } + .onEach { updatedModel -> state.update { it.toPreSign(updatedModel) } } + .launchIn(this) + + var listenMiddleJob: Job = listenMiddle() + + fun signFlow() = flow { onSign(state.updateAndGet { it.toSigning() }) } + .onEach { newState -> state.update { newState } } + .catch { exception -> + val errorResult = state.value.toResult(exception.left()) + state.update { errorResult } + } + + var signJob: Job? = null + + finalActionsChannel.receiveAsFlow() + .transformLatest { finalAction -> + when (finalAction) { + Action.Cancel -> { + onCancel(state.value) + channel.close() + } + Action.Sign -> { + val isSigningNow = signJob?.isActive == true + if (isSigningNow) return@transformLatest + listenMiddleJob.cancel() + signJob = launch { + signFlow().collect() + listenMiddleJob = listenMiddle() + } + } + } + } + .launchIn(this) + + /** + * keep flow running to attempt re-signing after an error + * or do something after a successful sign + */ + awaitClose() + } + + sealed interface Action { + data object Cancel : Action + data object Sign : Action + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt index bb1e86298b..a95ea4604b 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt @@ -1,12 +1,31 @@ package com.tangem.data.walletconnect.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.getNetwork import com.tangem.data.walletconnect.model.CAIP2 import com.tangem.data.walletconnect.model.NamespaceKey +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet internal interface WcNamespaceConverter { val namespaceKey: NamespaceKey + fun toBlockchain(chainId: CAIP2): Blockchain? - fun toCAIP2(blockchain: Blockchain): CAIP2? + fun toBlockchain(chainId: String): Blockchain? = toCAIP2(chainId)?.let { caip2 -> toBlockchain(caip2) } + + fun toCAIP2(network: Network): CAIP2? + fun toCAIP2(chainId: String): CAIP2? = CAIP2.fromRaw(chainId) + + fun toNetwork(chainId: String, wallet: UserWallet): Network? + fun toNetwork(chainId: String, wallet: UserWallet, excludedBlockchains: ExcludedBlockchains): Network? { + val blockchain = toBlockchain(chainId) ?: return null + return getNetwork( + blockchain = blockchain, + extraDerivationPath = null, + scanResponse = wallet.scanResponse, + excludedBlockchains = excludedBlockchains, + ) + } } \ 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 new file mode 100644 index 0000000000..2a6827c8c9 --- /dev/null +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -0,0 +1,247 @@ +package com.tangem.domain.walletconnect + +import app.cash.turbine.test +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.reown.walletkit.client.Wallet +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate +import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate +import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase +import com.tangem.data.walletconnect.pair.WcPairSdkDelegate +import com.tangem.data.walletconnect.utils.WcSdkSessionConverter +import com.tangem.domain.blockaid.BlockAidVerifier +import com.tangem.domain.walletconnect.model.WcPairError +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.usecase.pair.WcPairState +import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class DefaultWcPairUseCaseTest { + + private val sessionsManager: WcSessionsManager = mockk() + private val associateNetworksDelegate: AssociateNetworksDelegate = mockk() + private val caipNamespaceDelegate: CaipNamespaceDelegate = mockk() + private val sdkDelegate: WcPairSdkDelegate = mockk() + private val blockAidVerifier: BlockAidVerifier = mockk() + + private val url = "testUrl" + private val source = WcPairUseCase.Source.QR + private val loading = WcPairState.Loading + private val sdkProposal: Wallet.Model.SessionProposal + get() = Wallet.Model.SessionProposal( + pairingTopic = "", + name = "", + description = "", + url = "", + icons = listOf(), + redirect = "", + requiredNamespaces = mapOf(), + optionalNamespaces = mapOf(), + properties = mapOf(), + proposerPublicKey = "", + relayProtocol = "", + relayData = "", + ) + + private val unsupportedDApp = "Apex Pro" + private val unsupportedSdkProposal get() = sdkProposal.copy(name = unsupportedDApp) + + private val sessionForApprove: WcSessionApprove + get() = WcSessionApprove( + wallet = MockUserWalletFactory.create(), + network = listOf(), + ) + + private val sdkApprove: Wallet.Params.SessionApprove + get() = Wallet.Params.SessionApprove( + proposerPublicKey = "", + 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 = "", + topic = "", + expiry = 0L, + requiredNamespaces = mapOf(), + optionalNamespaces = mapOf(), + namespaces = mapOf(), + metaData = null, + ) + + private val Wallet.Model.Session.sessionForSave: WcSession + get() = WcSession( + wallet = sessionForApprove.wallet, + sdkModel = WcSdkSessionConverter.convert(this), + securityStatus = CheckDAppResult.SAFE, + ) + + private val useCase = DefaultWcPairUseCase( + sessionsManager = sessionsManager, + associateNetworksDelegate = associateNetworksDelegate, + caipNamespaceDelegate = caipNamespaceDelegate, + sdkDelegate = sdkDelegate, + blockAidVerifier = blockAidVerifier, + ) + + @Before + fun setup() { + coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf() + coEvery { + caipNamespaceDelegate.associate( + sessionProposal = sdkProposal, + userWallet = sessionForApprove.wallet, + networks = sessionForApprove.network, + ) + } returns mapOf() + } + + @Test + fun `pair, emmit proposal state and wait actions`() = runTest { + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + blockAidVerifier.verifyDApp(DAppData(sdkProposal.url)) + } + assert(awaitItem() is WcPairState.Proposal) + expectNoEvents() + } + } + + @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()) + + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.approve(sdkApprove) } returns sdkApproveSuccess.right() + coEvery { sessionsManager.saveSession(sessionForSave) } returns Unit + coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + blockAidVerifier.verifyDApp(DAppData(sdkProposal.url)) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.approve(sessionForApprove) + // ignore + useCase.reject() + + assertEquals(approveLoading, awaitItem()) + coVerifyOrder { + sdkDelegate.approve(sdkApprove) + sessionsManager.saveSession(sessionForSave) + } + assertEquals(result, awaitItem()) + awaitComplete() + } + } + + @Test + fun `success pair and reject approving`() = runTest { + val proposerPublicKey = sdkProposal.proposerPublicKey + + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.rejectSession(proposerPublicKey) } returns Unit + coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + blockAidVerifier.verifyDApp(DAppData(sdkProposal.url)) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.reject() + coVerifyOrder { + sdkDelegate.rejectSession(sdkProposal.proposerPublicKey) + } + awaitComplete() + } + } + + @Test + fun `success pair and reject unsupported dApp`() = runTest { + coEvery { sdkDelegate.pair(url) } returns unsupportedSdkProposal.right() + val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDApp) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assertEquals(unsupportedDAppError, awaitItem()) + awaitComplete() + } + } + + @Test + fun `complete on pair error`() = runTest { + val error = WcPairError.ExternalApprovalError("error") + coEvery { sdkDelegate.pair(url) } returns error.left() + val errorState = WcPairState.Error(error) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assertEquals(errorState, awaitItem()) + awaitComplete() + } + } + + @Test + fun `complete on approve error`() = runTest { + val approveLoading = WcPairState.Approving.Loading(sessionForApprove) + val error = WcPairError.ExternalApprovalError("error").left() + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.approve(sdkApprove) } returns error + coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } + + val errorResult = WcPairState.Approving.Result(sessionForApprove, error) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + associateNetworksDelegate.associate(sdkProposal) + blockAidVerifier.verifyDApp(DAppData(sdkProposal.url)) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.approve(sessionForApprove) + + assertEquals(approveLoading, awaitItem()) + coVerifyOrder { + sdkDelegate.approve(sdkApprove) + } + + assertEquals(errorResult, awaitItem()) + awaitComplete() + } + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt new file mode 100644 index 0000000000..39aaa7b68c --- /dev/null +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt @@ -0,0 +1,315 @@ +package com.tangem.domain.walletconnect + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import com.tangem.data.walletconnect.sign.FinalActionCollector +import com.tangem.data.walletconnect.sign.MiddleActionCollector +import com.tangem.data.walletconnect.sign.SignCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning +import com.tangem.data.walletconnect.sign.WcSignUseCaseDelegate +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class WcSignUseCaseDelegateTest { + + private var middleActionCollector: MiddleActionCollector = + object : MiddleActionCollector {} + private var finalActionCollector: FinalActionCollector = + object : FinalActionCollector {} + private val initSignModel = TestSignModel() + + private val initState = WcSignState(initSignModel, WcSignStep.PreSign) + private val signing = initState.toSigning() + private val result = signing.toResult(Unit.right()) + private val testException = RuntimeException("test") + + private val successSign: suspend FlowCollector>.( + currentState: WcSignState, + ) -> Unit = { state -> + delay(2) + emit(state.toResult(Unit.right())) + } + + private val failedSign: suspend FlowCollector>.( + currentState: WcSignState, + ) -> Unit + get() = { state -> + delay(2) + emit(state.toResult(testException.left())) + } + + @Before + fun setup() { + middleActionCollector = object : MiddleActionCollector {} + finalActionCollector = object : FinalActionCollector {} + } + + @Test + fun `invoke and keep flow running`() = runTest { + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + successSign(state) + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `success sign, keep flow running`() = runTest { + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + successSign(state) + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initModel = initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(result, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `failed sign, keep flow running`() = runTest { + val failedResult = signing.toResult(testException.left()) + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + failedSign(state) + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(failedResult, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `failed sign and catch unknown exception`() = runTest { + val exception = RuntimeException("asd") + val expectedErrorState = signing.toResult(exception.left()) + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + delay(2) + throw exception + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(expectedErrorState, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `interrupt signing and complete flow on cancel call`() = runTest { + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + delay(5) + emit(result) + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + delay(2) + useCase.cancel() + awaitComplete() + } + } + + @Test + fun `ignore multi time sign call till signed`() = runTest { + var count = 0 + val startLoading = WcSignState(TestSignModel("startLoading 1"), WcSignStep.Signing) + val startLoading2 = WcSignState(TestSignModel("startLoading 2"), WcSignStep.Signing) + val expectedSignResult = result + + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + // should emit single time in this test + emit(if (count % 2 == 0) startLoading else startLoading2) + count = count.inc() + delay(10) + emit(expectedSignResult) + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initSignModel).test { + useCase.sign() + delay(2) + assertEquals(startLoading, expectMostRecentItem()) + + // should ignore + useCase.sign() + delay(2) + expectNoEvents() + + // should ignore + useCase.sign() + expectNoEvents() + + delay(8) + assertEquals(expectedSignResult, expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `ignore middle actions while signing, on failed collect middle actions again`() = runTest { + val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr) + val firstMiddleUpdate = WcSignState( + signModel = firstTextMode, + domainStep = WcSignStep.PreSign, + ) + val startLoading = firstMiddleUpdate.toSigning() + val failedSign = startLoading.toResult(testException.left()) + val thirdMiddleUpdate = WcSignState( + signModel = TestSignModel(TestMiddleAction.Three().newTestStr), + domainStep = WcSignStep.PreSign, + ) + + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + delay(6) + emit(failedSign) + } + } + middleActionCollector = object : MiddleActionCollector { + override suspend fun FlowCollector.onMiddleAction( + signModel: TestSignModel, + middleAction: TestMiddleAction, + ) { + emit(signModel.copy(testStr = middleAction.newTestStr)) + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initSignModel).test { + delay(2) + useCase.middleAction(TestMiddleAction.One()) + assertEquals(firstMiddleUpdate, expectMostRecentItem()) + + useCase.sign() + assertEquals(startLoading, awaitItem()) + + // should ignore + delay(2) + useCase.middleAction(TestMiddleAction.Two()) + + delay(3) + assertEquals(failedSign, awaitItem()) + + // continue listen + delay(2) + useCase.middleAction(TestMiddleAction.Three()) + assertEquals(thirdMiddleUpdate, awaitItem()) + } + } + + @Test + fun `buffered middle actions and drop on sign call`() = runTest { + val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr) + val expectedFirst = initState.copy(signModel = firstTextMode) + val expectedSecond = initState.copy(signModel = TestSignModel(TestMiddleAction.Two().newTestStr)) + + finalActionCollector = object : FinalActionCollector { + override suspend fun SignCollector.onSign(state: WcSignState) { + successSign(state) + } + } + middleActionCollector = object : MiddleActionCollector { + override suspend fun FlowCollector.onMiddleAction( + signModel: TestSignModel, + middleAction: TestMiddleAction, + ) { + emit(signModel.copy(testStr = middleAction.newTestStr)) + delay(4) + } + } + val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.middleAction(TestMiddleAction.One()) + useCase.middleAction(TestMiddleAction.Two()) + // must be dropped + useCase.middleAction(TestMiddleAction.Three()) + + // 0 - 4 -> "one" is emitted + // 4 - 8 -> "two" is emitted + // 8 - 12 -> "Signing" is emitted, "three" ignored + delay(2) + assertEquals(expectedFirst, awaitItem()) + delay(4) + assertEquals(expectedSecond, awaitItem()) + + useCase.sign() + delay(4) + assertEquals(expectedSecond.toSigning(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + internal data class TestSignModel(val testStr: String = "testStr") + + internal sealed interface TestMiddleAction { + val newTestStr: String + + data class One(override val newTestStr: String = "Middle Action One") : TestMiddleAction + data class Two(override val newTestStr: String = "Middle Action Two") : TestMiddleAction + data class Three(override val newTestStr: String = "Middle Action Three") : TestMiddleAction + } +} \ No newline at end of file diff --git a/domain/blockaid/.gitignore b/domain/blockaid/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/blockaid/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/blockaid/build.gradle.kts b/domain/blockaid/build.gradle.kts new file mode 100644 index 0000000000..9fda0da981 --- /dev/null +++ b/domain/blockaid/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + /* Project - Domain */ + implementation(projects.domain.core) + implementation(projects.domain.blockaid.models) + + /* Other */ + implementation(deps.moshi.adapters) +} \ No newline at end of file diff --git a/domain/blockaid/models/.gitignore b/domain/blockaid/models/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/blockaid/models/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/blockaid/models/build.gradle.kts b/domain/blockaid/models/build.gradle.kts new file mode 100644 index 0000000000..14f878155b --- /dev/null +++ b/domain/blockaid/models/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.ksp) + id("configuration") +} +dependencies { + /* Other */ + implementation(deps.moshi) + ksp(deps.moshi.kotlin.codegen) +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/CheckDAppResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/CheckDAppResult.kt new file mode 100644 index 0000000000..480b3d8fd9 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/CheckDAppResult.kt @@ -0,0 +1,22 @@ +package com.domain.blockaid.models.dapp + +/** + * Result of BlockAid's DApp domain check + */ +enum class CheckDAppResult { + + /** + * DApp was confirmed safe + */ + SAFE, + + /** + * DApp was confirmed unsafe (known security risk) + */ + UNSAFE, + + /** + * Check wasn't performed, BlockAid cannot guarantee DApp's safety + */ + FAILED_TO_VERIFY, +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/DAppData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/DAppData.kt new file mode 100644 index 0000000000..25d1882031 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/DAppData.kt @@ -0,0 +1,11 @@ +package com.domain.blockaid.models.dapp + +/** + * Data BlockAid needs to verify DApp domain + * + * @property url DApp's domain url + */ +@JvmInline +value class DAppData( + val url: String, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt new file mode 100644 index 0000000000..d7244d6023 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt @@ -0,0 +1,12 @@ +package com.domain.blockaid.models.transaction + +/** + * Result of BlockAid's transaction check + * + * @property validation Indicates whether the transaction is considered safe or unsafe + * @property simulation Provides insight into the expected outcome of the transaction + */ +data class CheckTransactionResult( + val validation: ValidationResult, + val simulation: SimulationResult, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/SimulationResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/SimulationResult.kt new file mode 100644 index 0000000000..c44441a922 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/SimulationResult.kt @@ -0,0 +1,21 @@ +package com.domain.blockaid.models.transaction + +import com.domain.blockaid.models.transaction.simultation.SimulationData + +/** + * Result of BlockAid's transaction simulation + */ +sealed class SimulationResult { + + /** + * Simulation was successfully performed and returned data + */ + data class Success( + val data: SimulationData, + ) : SimulationResult() + + /** + * Simulation wasn't performed, BlockAid cannot guarantee transaction's behavior + */ + data object FailedToSimulate : SimulationResult() +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt new file mode 100644 index 0000000000..01fb3a9321 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt @@ -0,0 +1,36 @@ +package com.domain.blockaid.models.transaction + +/** + * Input data for BlockAid's transaction check + * + * @property chain Chain name, for ex. "ethereum" + * @property accountAddress The address of the account (wallet) received the request in hex string format + * @property method Transaction method, for ex. "eth_signTransaction" + * @property domainUrl Url of the DApp domain + */ +data class TransactionData( + val chain: String, + val accountAddress: String, + val method: String, + val domainUrl: String, + val params: TransactionParams, +) + +sealed class TransactionParams { + + /** + * Parameters for Ethereum based transactions, can be taken from [WcSdkSessionRequest.JSONRPCRequest.params] + */ + data class Evm( + val params: String, + ) : TransactionParams() + + /** + * Parameters for Solana transactions + * + * @property transactions Base64-encoded serialized list of transactions + */ + data class Solana( + val transactions: List, + ) : TransactionParams() +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/ValidationResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/ValidationResult.kt new file mode 100644 index 0000000000..0adde02aa8 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/ValidationResult.kt @@ -0,0 +1,22 @@ +package com.domain.blockaid.models.transaction + +/** + * Result of BlockAid's transaction validation + */ +enum class ValidationResult { + + /** + * Transaction was confirmed safe + */ + SAFE, + + /** + * Transaction was confirmed unsafe + */ + UNSAFE, + + /** + * Validation wasn't performed, BlockAid cannot guarantee transaction's safety + */ + FAILED_TO_VALIDATE, +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/AmountInfo.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/AmountInfo.kt new file mode 100644 index 0000000000..14e8b4c6d6 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/AmountInfo.kt @@ -0,0 +1,8 @@ +package com.domain.blockaid.models.transaction.simultation + +import java.math.BigDecimal + +data class AmountInfo( + val amount: BigDecimal, + val token: TokenInfo, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/ApprovedAmount.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/ApprovedAmount.kt new file mode 100644 index 0000000000..6fdad58ec2 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/ApprovedAmount.kt @@ -0,0 +1,9 @@ +package com.domain.blockaid.models.transaction.simultation + +import java.math.BigDecimal + +data class ApprovedAmount( + val approvedAmount: BigDecimal, + val isUnlimited: Boolean, + val tokenInfo: TokenInfo, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/SimulationData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/SimulationData.kt new file mode 100644 index 0000000000..afd8fea9e9 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/SimulationData.kt @@ -0,0 +1,22 @@ +package com.domain.blockaid.models.transaction.simultation + +/** + * Result of a successful transaction simulation. + */ +sealed class SimulationData { + + /** + * Represents a swap/send/sell operations with specified send and receive amounts (can be multiple amounts for NFT) + */ + data class SendAndReceive( + val send: List, + val receive: List, + ) : SimulationData() + + /** + * Represents an approve operation with the specified amount (can be multiple amounts for NFT) + */ + data class Approve( + val approvedAmounts: List, + ) : SimulationData() +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/TokenInfo.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/TokenInfo.kt new file mode 100644 index 0000000000..889bcf1d1e --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/TokenInfo.kt @@ -0,0 +1,7 @@ +package com.domain.blockaid.models.transaction.simultation + +data class TokenInfo( + val chainId: Int?, + val logoUrl: String?, + val symbol: String, +) \ No newline at end of file diff --git a/domain/blockaid/src/main/kotlin/com/tangem/domain/blockaid/BlockAidVerifier.kt b/domain/blockaid/src/main/kotlin/com/tangem/domain/blockaid/BlockAidVerifier.kt new file mode 100644 index 0000000000..e41f57e783 --- /dev/null +++ b/domain/blockaid/src/main/kotlin/com/tangem/domain/blockaid/BlockAidVerifier.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.blockaid + +import arrow.core.Either +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData + +/** + * Verifies the safety of DApps and WalletConnect transactions + */ +interface BlockAidVerifier { + + /** + * Checks if a DApp is safe to use + */ + suspend fun verifyDApp(data: DAppData): Either + + /** + * Checks the safety of a WalletConnect transaction and provides a simulation result + */ + suspend fun verifyTransaction(data: TransactionData): Either +} \ No newline at end of file diff --git a/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt b/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt index 5c667f1f53..8ed8931539 100644 --- a/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt +++ b/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt @@ -1,6 +1,7 @@ package com.tangem.domain.demo import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionSendResult @@ -24,8 +25,12 @@ class DemoTransactionSender(private val walletManager: WalletManager) : Transact ) } - override suspend fun estimateFee(amount: Amount, destination: String): Result { - return getFee(amount, walletManager.wallet.address) + override suspend fun estimateFee( + amount: Amount, + destination: String, + callData: SmartContractCallData?, + ): Result { + return getFee(amount, walletManager.wallet.address, callData) } override suspend fun send( diff --git a/domain/feedback/build.gradle.kts b/domain/feedback/build.gradle.kts index 28fe483e00..213c40c655 100644 --- a/domain/feedback/build.gradle.kts +++ b/domain/feedback/build.gradle.kts @@ -15,4 +15,5 @@ dependencies { implementation(projects.core.res) implementation(projects.domain.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.visa.models) } \ 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 c2726a604f..7d0f5aa49f 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 @@ -2,12 +2,43 @@ package com.tangem.domain.feedback import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.utils.breakLine +import com.tangem.domain.visa.model.VisaTxDetails import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses internal class FeedbackDataBuilder { private val builder = StringBuilder() + fun addVisaTxInfo(txDetails: VisaTxDetails) { + builder.appendKeyValue("Type", txDetails.type) + builder.appendKeyValue("Status", txDetails.status) + builder.appendKeyValue("Blockchain amount", txDetails.blockchainAmount.toString()) + builder.appendKeyValue("Transaction amount", txDetails.transactionAmount.toString()) + builder.appendKeyValue("Currency code", txDetails.transactionCurrencyCode.toString()) + builder.appendKeyValue("Merchant name", txDetails.merchantName) + builder.appendKeyValue("Merchant city", txDetails.merchantCity) + builder.appendKeyValue("Merchant country code", txDetails.merchantCountryCode) + builder.appendKeyValue("Merchant category code", txDetails.merchantCategoryCode) + + builder.appendDelimiter() + builder.breakLine() + builder.append("Requests:") + + txDetails.requests.forEach { request -> + builder.appendKeyValue("Type", request.requestType) + builder.appendKeyValue("Status", request.requestStatus) + builder.appendKeyValue("Blockchain amount", request.blockchainAmount.toString()) + builder.appendKeyValue("Transaction amount", request.transactionAmount.toString()) + builder.appendKeyValue("Currency code", request.txCurrencyCode.toString()) + builder.appendKeyValue("Error code", request.errorCode.toString()) + builder.appendKeyValue("Date", request.requestDate.toString()) + builder.appendKeyValue("Transaction hash", request.txHash) + builder.appendKeyValue("Transaction status", request.txStatus) + builder.appendDelimiter() + builder.breakLine() + } + } + fun addUserWalletsInfo(userWalletsInfo: UserWalletsInfo) { builder.appendKeyValue("User Wallet ID", userWalletsInfo.selectedUserWalletId) builder.appendKeyValue("Total saved wallets", userWalletsInfo.totalUserWallets.toString()) 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 7bb6e53a9b..a94664bc9a 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 @@ -2,7 +2,6 @@ package com.tangem.domain.feedback import android.content.res.Resources import com.tangem.core.res.getStringSafe -import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmail import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository @@ -27,7 +26,7 @@ class SendFeedbackEmailUseCase( suspend operator fun invoke(type: FeedbackEmailType) { val email = FeedbackEmail( - address = getAddress(type.cardInfo), + address = getAddress(type), subject = emailSubjectResolver.resolve(type), message = createMessage(type), // Temporally user data is not sent @@ -37,8 +36,12 @@ class SendFeedbackEmailUseCase( feedbackRepository.sendEmail(email) } - private fun getAddress(cardInfo: CardInfo?): String { - return if (cardInfo?.isStart2Coin == true) START2COIN_SUPPORT_EMAIL else TANGEM_SUPPORT_EMAIL + 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 + else -> TANGEM_SUPPORT_EMAIL + } } private suspend fun createMessage(type: FeedbackEmailType): String { @@ -61,12 +64,15 @@ class SendFeedbackEmailUseCase( is FeedbackEmailType.CurrencyDescriptionError, is FeedbackEmailType.PreActivatedWallet, is FeedbackEmailType.CardAttestationFailed, + is FeedbackEmailType.Visa.Dispute, -> this is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.RateCanBeBetter, is FeedbackEmailType.StakingProblem, is FeedbackEmailType.SwapProblem, is FeedbackEmailType.TransactionSendingProblem, + is FeedbackEmailType.Visa.Activation, + is FeedbackEmailType.Visa.DirectUserRequest, -> { append(resources.getStringSafe(R.string.feedback_data_collection_message)) skipLine() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt index 681cb957c0..d6d83ea329 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt @@ -11,6 +11,7 @@ data class CardInfo( val signedHashesList: List, val isImported: Boolean, val isStart2Coin: Boolean, + val isVisa: Boolean, ) { data class SignedHashes(val curve: String, val total: String?) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 364c363194..7853427ca5 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -1,5 +1,7 @@ package com.tangem.domain.feedback.models +import com.tangem.domain.visa.model.VisaTxDetails + /** * Email feedback type * @@ -52,4 +54,15 @@ sealed interface FeedbackEmailType { data object CardAttestationFailed : FeedbackEmailType { override val cardInfo: CardInfo? = null } + + sealed class Visa : FeedbackEmailType { + data class DirectUserRequest(override val cardInfo: CardInfo) : Visa() + + data class Activation(override val cardInfo: CardInfo) : Visa() + + data class Dispute( + val visaTxDetails: VisaTxDetails, + override val cardInfo: CardInfo, + ) : Visa() + } } \ No newline at end of file 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 e531f71d3f..7711abe1d1 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 @@ -4,6 +4,7 @@ import com.tangem.domain.feedback.FeedbackDataBuilder import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.visa.model.VisaTxDetails /** * Email message body resolver @@ -29,11 +30,20 @@ internal class EmailMessageBodyResolver( 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) } return build() } + private suspend fun FeedbackDataBuilder.addVisaRequestBody(cardInfo: CardInfo, visaTxDetails: VisaTxDetails) { + addUserRequestBody(cardInfo) + addDelimiter() + addVisaTxInfo(visaTxDetails) + } + private suspend fun FeedbackDataBuilder.addUserRequestBody(cardInfo: CardInfo) { addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(cardInfo.userWalletId)) addDelimiter() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt index 30058b611e..1708558099 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt @@ -20,6 +20,9 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.CurrencyDescriptionError, is FeedbackEmailType.CardAttestationFailed, + is FeedbackEmailType.Visa.Activation, + is FeedbackEmailType.Visa.DirectUserRequest, + is FeedbackEmailType.Visa.Dispute, -> R.string.feedback_preface_support is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed 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 cc2b6c379c..849d1ce0c4 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 @@ -37,6 +37,9 @@ internal class EmailSubjectResolver(private val resources: Resources) { resources.getStringSafe(R.string.feedback_token_description_error) } FeedbackEmailType.CardAttestationFailed -> "Card attestation failed" + is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}" + is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}" + is FeedbackEmailType.Visa.Dispute -> "[Visa] [DISPUTE] {auto-filled subject}" } } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt index aeaa917e5f..e1ec372cc9 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt @@ -3,4 +3,5 @@ package com.tangem.domain.feedback.utils internal const val GMAIL_MAX_FILE_SIZE = 24_900_000 // ≈ 25 MB internal const val START2COIN_SUPPORT_EMAIL = "cardsupport@start2coin.com" -internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" \ No newline at end of file +internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" +internal const val TANGEM_VISA_SUPPORT_EMAIL = "pay@tangem.com" \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt b/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt index 2bc3ecbc4a..758ec7a589 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt @@ -57,7 +57,7 @@ object VisaWalletPublicKeyUtility { either { val address = generateAddressOnSecp256k1(publicKey).bind() - if (address.value != targetAddress) { + if (address.value.lowercase() != targetAddress.lowercase()) { raise(VisaActivationError.AddressNotMatched) } } diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt index d15b9f68bd..765662941b 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -10,6 +10,7 @@ import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchain.common.address.EstimationFeeAddressFactory import com.tangem.blockchain.common.pagination.Page +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.trustlines.AssetRequirementsManager @@ -276,10 +277,7 @@ class DefaultWalletManagersFacade( } } - private suspend fun getUserWallet(userWalletId: UserWalletId) = - requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { - "Unable to find a user wallet with provided ID: $userWalletId" - } + private fun getUserWallet(userWalletId: UserWalletId) = userWalletsStore.getSyncStrict(userWalletId) private suspend fun getAndUpdateWalletManager( userWallet: UserWallet, @@ -488,9 +486,20 @@ class DefaultWalletManagersFacade( val destination = estimationFeeAddressFactory.makeAddress(blockchain) + val callData = if (amount.type is AmountType.Token) { + SmartContractCallDataProviderFactory.getTokenTransferCallData( + destinationAddress = destination, + amount = amount, + blockchain = blockchain, + ) + } else { + null + } + (walletManager as? TransactionSender)?.estimateFee( amount = amount, destination = destination, + callData = callData, ) } @@ -697,6 +706,11 @@ class DefaultWalletManagersFacade( return walletManager.getSalePrice(collectionIdentifier, assetIdentifier) } + override suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? { + val blockchain = Blockchain.fromId(network.id.value) + return blockchain.getNFTExploreUrl(assetIdentifier) + } + override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean { val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt index b40c497186..45a93fff76 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -273,6 +273,8 @@ interface WalletManagersFacade { assetIdentifier: NFTAsset.Identifier, ): NFTAsset.SalePrice? + suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? + /** * If wallet manager implements [InitializableAccount] then returns [InitializableAccount.isAccountInitialized] * value. Otherwise always return true diff --git a/domain/manage-tokens/build.gradle.kts b/domain/manage-tokens/build.gradle.kts index ef5facece8..cff1f6e9aa 100644 --- a/domain/manage-tokens/build.gradle.kts +++ b/domain/manage-tokens/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { api(projects.domain.core) api(projects.domain.manageTokens.models) api(projects.domain.networks) + api(projects.domain.quotes) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) implementation(projects.domain.staking) diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt index 9f78e90712..8379c39929 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency @@ -26,6 +27,7 @@ class SaveManagedTokensUseCase( private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -124,10 +126,19 @@ class SaveManagedTokensUseCase( } private suspend fun refreshUpdatedQuotes(addedCurrencies: List) { - quotesRepository.fetchQuotes( - currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, - refresh = true, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + appCurrencyId = null, + ), + ) + } else { + quotesRepository.fetchQuotes( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + refresh = true, + ) + } } /** diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 6ba3a16199..7d820cb19a 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { api(projects.domain.models) api(projects.domain.networks) api(projects.domain.staking) + api(projects.domain.quotes) api(projects.domain.wallets) api(projects.domain.wallets.models) diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt index dcd308db12..080a102047 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt @@ -3,6 +3,9 @@ package com.tangem.domain.markets import arrow.core.None import arrow.core.Option import arrow.core.toOption +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.repository.QuotesRepository @@ -11,9 +14,10 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -@Suppress("UnusedPrivateMember") class GetCurrencyQuotesUseCase( private val quotesRepository: QuotesRepository, + private val singleQuoteSupplier: SingleQuoteSupplier, + private val tokensFeatureToggles: TokensFeatureToggles, ) { // TODO apply interval parameter [REDACTED_TASK_KEY] operator fun invoke( @@ -23,9 +27,18 @@ class GetCurrencyQuotesUseCase( ): Flow> { val rawId = currencyID.rawCurrencyId ?: return flowOf(None) - return quotesRepository.getQuotesUpdatesLegacy( - currenciesIds = setOf(rawId), - refresh = refresh, - ).map { it.filterIsInstance().firstOrNull().toOption() }.catch { emit(None) } + return if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + singleQuoteSupplier( + params = SingleQuoteProducer.Params(rawCurrencyId = rawId), + ) + .map { (it as? Quote.Value).toOption() } + } else { + quotesRepository.getQuotesUpdatesLegacy( + currenciesIds = setOf(rawId), + refresh = refresh, + ) + .map { it.filterIsInstance().firstOrNull().toOption() } + } + .catch { emit(None) } } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt index 4d086677b5..06b6a81591 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency @@ -31,6 +32,7 @@ class SaveMarketTokensUseCase( private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -106,9 +108,18 @@ class SaveMarketTokensUseCase( } private suspend fun refreshUpdatedQuotes(addedCurrencies: List) { - quotesRepository.fetchQuotes( - currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, - refresh = true, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + appCurrencyId = null, + ), + ) + } else { + quotesRepository.fetchQuotes( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + refresh = true, + ) + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt new file mode 100644 index 0000000000..777d3355d8 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.models + +data class ArtworkModel( + val verifiedArtwork: ByteArray? = null, + val defaultUrl: String, +) \ No newline at end of file diff --git a/domain/nft/build.gradle.kts b/domain/nft/build.gradle.kts index b5ce316d58..518f509278 100644 --- a/domain/nft/build.gradle.kts +++ b/domain/nft/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) + implementation(projects.core.analytics.models) implementation(projects.core.utils) implementation(projects.domain.core) diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt index 3e2558f928..2c75ead2f4 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt @@ -55,6 +55,14 @@ data class NFTAsset( override val stringValue: String = tokenAddress } + @Serializable + data class Solana( + val tokenAddress: String, + val cnft: Boolean, + ) : Identifier() { + override val stringValue: String = tokenAddress + } + @Serializable data object Unknown : Identifier() { override val stringValue: String = "" diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt index d75d39c6de..eee0569d01 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt @@ -40,6 +40,9 @@ data class NFTCollection( @Serializable data class TON(val contractAddress: String?) : Identifier() + @Serializable + data class Solana(val collection: String?) : Identifier() + @Serializable data object Unknown : Identifier() } diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTNetworks.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTNetworks.kt new file mode 100644 index 0000000000..2cdbcfa4a3 --- /dev/null +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTNetworks.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.nft.models + +import com.tangem.domain.tokens.model.Network + +data class NFTNetworks( + val availableNetworks: List, + val unavailableNetworks: List, +) \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt index 3308232789..21f06eb54f 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt @@ -1,5 +1,6 @@ package com.tangem.domain.nft +import com.tangem.domain.nft.models.NFTNetworks import com.tangem.domain.tokens.model.Network import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext @@ -7,11 +8,16 @@ import kotlinx.coroutines.withContext class FilterNFTAvailableNetworksUseCase( private val dispatchers: CoroutineDispatcherProvider, ) { - suspend operator fun invoke(networks: List, searchQuery: String): List = + suspend operator fun invoke(networks: NFTNetworks, searchQuery: String): NFTNetworks = withContext(dispatchers.default) { - networks.filter { - it.name.contains(searchQuery, ignoreCase = true) || - it.currencySymbol.contains(searchQuery, ignoreCase = true) - } + NFTNetworks( + availableNetworks = networks.availableNetworks.filter(searchQuery), + unavailableNetworks = networks.unavailableNetworks.filter(searchQuery), + ) } + + private fun List.filter(searchQuery: String) = filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.currencySymbol.contains(searchQuery, ignoreCase = true) + } } \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTAvailableNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTAvailableNetworksUseCase.kt deleted file mode 100644 index 203f12e012..0000000000 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTAvailableNetworksUseCase.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.domain.nft - -import com.tangem.domain.nft.repository.NFTRepository -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.wallets.models.UserWalletId -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -class GetNFTAvailableNetworksUseCase( - private val currenciesRepository: CurrenciesRepository, - private val nftRepository: NFTRepository, -) { - operator fun invoke(userWalletId: UserWalletId): Flow> = currenciesRepository - .getWalletCurrenciesUpdates(userWalletId) - .map { cryptoCurrencies -> - cryptoCurrencies - .map { cryptoCurrency -> cryptoCurrency.network } - .distinct() - .filter { nftRepository.isNFTSupported(it) } - } -} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTExploreUrlUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTExploreUrlUseCase.kt new file mode 100644 index 0000000000..d6794fd7fe --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTExploreUrlUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.nft + +import arrow.core.raise.catch +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.model.Network + +class GetNFTExploreUrlUseCase( + private val nftRepository: NFTRepository, +) { + + suspend operator fun invoke(network: Network, assetIdentifier: NFTAsset.Identifier): String? = catch( + block = { + nftRepository.getNFTExploreUrl( + network = network, + assetIdentifier = assetIdentifier, + ) + }, + catch = { null }, + ) +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt new file mode 100644 index 0000000000..8a87f0ca6f --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.nft + +import com.tangem.domain.nft.models.NFTNetworks +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetNFTNetworksUseCase( + private val currenciesRepository: CurrenciesRepository, + private val nftRepository: NFTRepository, +) { + operator fun invoke(userWalletId: UserWalletId): Flow = currenciesRepository + .getWalletCurrenciesUpdates(userWalletId) + .map { cryptoCurrencies -> + val availableNetworks = cryptoCurrencies + .map { cryptoCurrency -> cryptoCurrency.network } + .filter { nftRepository.isNFTSupported(it) } + .sortedBy { it.name } + + val unavailableNetworks = nftRepository + .getNFTSupportedNetworks(userWalletId) + .filter { supportedNetwork -> availableNetworks.none { it.id == supportedNetwork.id } } + .sortedBy { it.name } + + NFTNetworks( + availableNetworks = availableNetworks, + unavailableNetworks = unavailableNetworks, + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..2e365e88a8 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/RefreshAllNFTUseCase.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.nft + +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId + +class RefreshAllNFTUseCase( + private val currenciesRepository: CurrenciesRepository, + private val nftRepository: NFTRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId) { + val currencies = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) + nftRepository.refreshAll(userWalletId, currencies.map { it.network }.distinct()) + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt new file mode 100644 index 0000000000..dbb2139d18 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt @@ -0,0 +1,60 @@ +package com.tangem.domain.nft.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.COUNT +import com.tangem.core.analytics.models.AnalyticsParam.Key.STATE + +sealed class NFTAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent( + category = "NFT", + event = event, + params = params, +) { + + data class NFTListScreenOpened( + val state: State, + ) : NFTAnalyticsEvent( + event = "NFT List Screen Opened", + params = buildMap { + put(STATE, state.value) + if (state is State.Full) { + put(COUNT, state.count.toString()) + } + }, + ) { + sealed class State(val value: String) { + data object Empty : State("Empty") + data class Full(val count: Int) : State("Full") + } + } + + object Receive { + data object ScreenOpened : NFTAnalyticsEvent(event = "Receive NFT Screen Opened") + + data class BlockchainChosen( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "Blockchain Chosen", params = mapOf(BLOCKCHAIN to blockchain)) + + data class CopyAddress( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "Button - Copy Address", params = mapOf(BLOCKCHAIN to blockchain)) + + data class ShareAddress( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "Button - Share Address", params = mapOf(BLOCKCHAIN to blockchain)) + } + + object Details { + data class ScreenOpened( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "NFT Details Screen Opened", params = mapOf(BLOCKCHAIN to blockchain)) + + data object ButtonReadMore : NFTAnalyticsEvent(event = "Button - Read More") + data object ButtonSeeAll : NFTAnalyticsEvent(event = "Button - See All") + data object ButtonExplore : NFTAnalyticsEvent(event = "Button - Explore") + data object ButtonSend : NFTAnalyticsEvent(event = "Button - Send") + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt index 5d811e9a39..1484b241a3 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.nft.repository +import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.nft.models.NFTCollections import com.tangem.domain.tokens.model.Network @@ -13,5 +14,11 @@ interface NFTRepository { suspend fun refreshAssets(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier) + suspend fun refreshAll(userWalletId: UserWalletId, networks: List) + suspend fun isNFTSupported(network: Network): Boolean + + suspend fun getNFTSupportedNetworks(userWalletId: UserWalletId): List + + suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? } \ No newline at end of file diff --git a/domain/notifications/.gitignore b/domain/notifications/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/domain/notifications/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/notifications/build.gradle.kts b/domain/notifications/build.gradle.kts new file mode 100644 index 0000000000..ae59c08bd3 --- /dev/null +++ b/domain/notifications/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +dependencies { + implementation(projects.domain.core) + implementation(projects.domain.notifications.models) + + /* Tests */ + testImplementation(deps.test.junit) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) +} \ No newline at end of file diff --git a/domain/notifications/models/.gitignore b/domain/notifications/models/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/domain/notifications/models/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/notifications/models/build.gradle.kts b/domain/notifications/models/build.gradle.kts new file mode 100644 index 0000000000..7dd715c617 --- /dev/null +++ b/domain/notifications/models/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} \ No newline at end of file diff --git a/domain/notifications/models/src/main/java/com/tangem/domain/notifications/models/NotificationsEligibleNetwork.kt b/domain/notifications/models/src/main/java/com/tangem/domain/notifications/models/NotificationsEligibleNetwork.kt new file mode 100644 index 0000000000..7cd89c6754 --- /dev/null +++ b/domain/notifications/models/src/main/java/com/tangem/domain/notifications/models/NotificationsEligibleNetwork.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.notifications.models + +data class NotificationsEligibleNetwork( + val id: String, + val networkId: String, + val name: String, +) \ No newline at end of file diff --git a/domain/notifications/src/main/java/com/tangem/domain/notifications/GetApplicationIdUseCase.kt b/domain/notifications/src/main/java/com/tangem/domain/notifications/GetApplicationIdUseCase.kt new file mode 100644 index 0000000000..026d3a89c6 --- /dev/null +++ b/domain/notifications/src/main/java/com/tangem/domain/notifications/GetApplicationIdUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.notifications + +import arrow.core.Either +import com.tangem.domain.notifications.repository.NotificationsRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class GetApplicationIdUseCase( + private val notificationsRepository: NotificationsRepository, +) { + private val mutex = Mutex() + + suspend operator fun invoke(): Either = Either.catch { + val localApplicationId = notificationsRepository.getApplicationId() + if (localApplicationId != null) return@catch localApplicationId + + mutex.withLock { + val doubleCheckedId = notificationsRepository.getApplicationId() + if (doubleCheckedId != null) return@withLock doubleCheckedId + + val newApplicationId = notificationsRepository.createApplicationId() + notificationsRepository.saveApplicationId(newApplicationId) + newApplicationId + } + } +} \ No newline at end of file diff --git a/domain/notifications/src/main/java/com/tangem/domain/notifications/repository/NotificationsRepository.kt b/domain/notifications/src/main/java/com/tangem/domain/notifications/repository/NotificationsRepository.kt new file mode 100644 index 0000000000..5fecc4b7e4 --- /dev/null +++ b/domain/notifications/src/main/java/com/tangem/domain/notifications/repository/NotificationsRepository.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.notifications.repository + +import com.tangem.domain.notifications.models.NotificationsEligibleNetwork + +interface NotificationsRepository { + + @Throws + suspend fun createApplicationId(pushToken: String? = null): String + + suspend fun saveApplicationId(appId: String) + + suspend fun getApplicationId(): String? + + @Throws + suspend fun setNotificationsEnabledForWallet(walletId: String, enabled: Boolean) + + @Throws + suspend fun associateApplicationIdWithWallets(appId: String, wallets: List) + + @Throws + suspend fun isNotificationsEnabledForWallet(walletId: String): Boolean + + @Throws + suspend fun setWalletName(walletId: String, walletName: String) + + @Throws + suspend fun getWalletName(walletId: String): String? + + @Throws + suspend fun sendPushToken(appId: String, pushToken: String) + + @Throws + suspend fun getEligibleNetworks(): List +} \ No newline at end of file diff --git a/domain/notifications/src/test/java/com/tangem/domain/notifications/GetApplicationIdUseCaseTest.kt b/domain/notifications/src/test/java/com/tangem/domain/notifications/GetApplicationIdUseCaseTest.kt new file mode 100644 index 0000000000..8f562e98b7 --- /dev/null +++ b/domain/notifications/src/test/java/com/tangem/domain/notifications/GetApplicationIdUseCaseTest.kt @@ -0,0 +1,154 @@ +package com.tangem.domain.notifications + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.notifications.repository.NotificationsRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.net.SocketTimeoutException + +class GetApplicationIdUseCaseTest { + + private val notificationsRepository: NotificationsRepository = mockk() + private val useCase = GetApplicationIdUseCase(notificationsRepository) + + @Test + fun `GIVEN local application ID exists WHEN invoke THEN return local application ID`() = runTest { + // GIVEN + val expectedApplicationId = "test-app-id" + coEvery { notificationsRepository.getApplicationId() } returns expectedApplicationId + + // WHEN + val result = useCase() + + // THEN + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isEqualTo(expectedApplicationId) + coVerify(exactly = 1) { notificationsRepository.getApplicationId() } + coVerify(inverse = true) { + notificationsRepository.createApplicationId() + notificationsRepository.saveApplicationId(any()) + } + } + + @Test + fun `GIVEN local application ID does not exist WHEN invoke THEN create and save new application ID`() = runTest { + // GIVEN + val newApplicationId = "new-app-id" + coEvery { notificationsRepository.getApplicationId() } returns null + coEvery { notificationsRepository.createApplicationId() } returns newApplicationId + coEvery { notificationsRepository.saveApplicationId(newApplicationId) } returns Unit + + // WHEN + val result = useCase() + + // THEN + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isEqualTo(newApplicationId) + coVerifyOrder { + notificationsRepository.getApplicationId() + notificationsRepository.getApplicationId() + notificationsRepository.createApplicationId() + notificationsRepository.saveApplicationId(newApplicationId) + } + } + + @Test + fun `GIVEN repository throws exception WHEN invoke THEN return Either Left with error`() = runTest { + // GIVEN + val expectedError = SocketTimeoutException("Test error") + coEvery { notificationsRepository.getApplicationId() } throws expectedError + + // WHEN + val result = useCase() + + // THEN + assertThat(result).isInstanceOf(Either.Left::class.java) + assertThat((result as Either.Left).value).isEqualTo(expectedError) + coVerify(exactly = 1) { notificationsRepository.getApplicationId() } + coVerify(inverse = true) { + notificationsRepository.createApplicationId() + notificationsRepository.saveApplicationId(any()) + } + } + + @Test + fun `GIVEN no local application ID WHEN multiple concurrent invokes THEN create only one application ID`() = + runTest { + // GIVEN + val newApplicationId = "new-app-id" + var isIdCreated = false + + coEvery { notificationsRepository.getApplicationId() } answers { + if (!isIdCreated) null else newApplicationId + } + coEvery { notificationsRepository.createApplicationId() } answers { + isIdCreated = true + newApplicationId + } + coEvery { notificationsRepository.saveApplicationId(newApplicationId) } returns Unit + + // WHEN + val results = coroutineScope { + List(PARALLEL_COUNT) { + async { + delay(100) + useCase() + } + }.awaitAll() + } + + // THEN + results.forEach { result -> + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isEqualTo(newApplicationId) + } + coVerify(exactly = PARALLEL_COUNT + 1) { notificationsRepository.getApplicationId() } + coVerify(exactly = 1) { notificationsRepository.createApplicationId() } + coVerify(exactly = 1) { notificationsRepository.saveApplicationId(newApplicationId) } + } + + @Test + fun `GIVEN no local application ID WHEN multiple concurrent invokes with delay THEN create only one application ID`() = runTest { + // GIVEN + val newApplicationId = "new-app-id" + var isIdCreated = false + + coEvery { notificationsRepository.getApplicationId() } answers { + if (!isIdCreated) null else newApplicationId + } + coEvery { notificationsRepository.createApplicationId() } answers { + isIdCreated = true + newApplicationId + } + coEvery { notificationsRepository.saveApplicationId(newApplicationId) } returns Unit + + // WHEN + val results = coroutineScope { + List(PARALLEL_COUNT) { + async { + delay(100) + useCase() + } + }.awaitAll() + } + + // THEN + results.forEach { result -> + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isEqualTo(newApplicationId) + } + coVerify(exactly = PARALLEL_COUNT + 1) { notificationsRepository.getApplicationId() } + coVerify(exactly = 1) { notificationsRepository.createApplicationId() } + coVerify(exactly = 1) { notificationsRepository.saveApplicationId(newApplicationId) } + } + + companion object { + private const val PARALLEL_COUNT = 100 + } +} \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampStatus.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampStatus.kt index e116c0c085..1b7952e042 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampStatus.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampStatus.kt @@ -31,6 +31,8 @@ data class OnrampStatus( Paid(order = 8), Sending(order = 9), Finished(order = 10), + RefundInProgress(order = 11), + Refunded(order = 12), ; val isTerminal: Boolean @@ -39,6 +41,7 @@ data class OnrampStatus( Failed, Paused, Finished, + Refunded, -> true Created, WaitingForPayment, @@ -46,6 +49,7 @@ data class OnrampStatus( Verifying, Paid, Sending, + RefundInProgress, -> false } @@ -62,6 +66,8 @@ data class OnrampStatus( Verifying, Paid, Sending, + RefundInProgress, + Refunded, -> false } @@ -78,6 +84,8 @@ data class OnrampStatus( Paid, Sending, Finished, + RefundInProgress, + Refunded, -> false } } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/CheckOnrampAvailabilityUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/CheckOnrampAvailabilityUseCase.kt index 60cb522991..911ca71568 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/CheckOnrampAvailabilityUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/CheckOnrampAvailabilityUseCase.kt @@ -6,27 +6,31 @@ import com.tangem.domain.onramp.model.OnrampCountry 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.wallets.models.UserWallet class CheckOnrampAvailabilityUseCase( private val repository: OnrampRepository, private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(): Either { + suspend operator fun invoke(userWallet: UserWallet): Either { return Either.catch { - repository.fetchPaymentMethodsIfAbsent() + repository.fetchPaymentMethodsIfAbsent(userWallet) val savedCountry = repository.getDefaultCountrySync() if (savedCountry != null) { - proceedWithSavedCountry(savedCountry = savedCountry) + proceedWithSavedCountry(userWallet = userWallet, savedCountry = savedCountry) } else { - val detectedCountry = repository.getCountryByIp() + val detectedCountry = repository.getCountryByIp(userWallet) OnrampAvailability.ConfirmResidency(detectedCountry) } }.mapLeft(errorResolver::resolve) } - private suspend fun proceedWithSavedCountry(savedCountry: OnrampCountry): OnrampAvailability { - val countries = repository.fetchCountries() + private suspend fun proceedWithSavedCountry( + userWallet: UserWallet, + savedCountry: OnrampCountry, + ): OnrampAvailability { + val countries = repository.fetchCountries(userWallet) val updatedCountry = countries.find { it.id == savedCountry.id } ?: savedCountry return if (updatedCountry.onrampAvailable) { val currency = repository.getDefaultCurrencySync() ?: run { diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCountriesUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCountriesUseCase.kt index 1920178751..5c9cd186d9 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCountriesUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCountriesUseCase.kt @@ -4,13 +4,14 @@ import arrow.core.Either 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.wallets.models.UserWallet class FetchOnrampCountriesUseCase( private val repository: OnrampRepository, private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(): Either { - return Either.catch { repository.fetchCountries() }.mapLeft(errorResolver::resolve) + suspend operator fun invoke(userWallet: UserWallet): Either { + return Either.catch { repository.fetchCountries(userWallet) }.mapLeft(errorResolver::resolve) } } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCurrenciesUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCurrenciesUseCase.kt index d1a15ca673..facbee7dab 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCurrenciesUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/FetchOnrampCurrenciesUseCase.kt @@ -4,13 +4,14 @@ import arrow.core.Either 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.wallets.models.UserWallet class FetchOnrampCurrenciesUseCase( private val repository: OnrampRepository, private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(): Either { - return Either.catch { repository.fetchCurrencies() }.mapLeft(errorResolver::resolve) + suspend operator fun invoke(userWallet: UserWallet): Either { + return Either.catch { repository.fetchCurrencies(userWallet) }.mapLeft(errorResolver::resolve) } } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountryUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountryUseCase.kt index 65c945e7b5..9802404640 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountryUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampCountryUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.domain.onramp.model.OnrampCountry 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.wallets.models.UserWallet import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -24,8 +25,8 @@ class GetOnrampCountryUseCase( } } - suspend fun invokeSync(): Either { - return Either.catch { repository.getDefaultCountrySync() ?: repository.getCountryByIp() } + suspend fun invokeSync(userWallet: UserWallet): Either { + return Either.catch { repository.getDefaultCountrySync() ?: repository.getCountryByIp(userWallet) } .mapLeft(errorResolver::resolve) } } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampRedirectUrlUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampRedirectUrlUseCase.kt index 55ba74a5bf..dae9cfdb2a 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampRedirectUrlUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampRedirectUrlUseCase.kt @@ -7,7 +7,7 @@ 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.tokens.model.CryptoCurrency -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet class GetOnrampRedirectUrlUseCase( private val repository: OnrampRepository, @@ -16,14 +16,14 @@ class GetOnrampRedirectUrlUseCase( ) { suspend operator fun invoke( - userWalletId: UserWalletId, + userWallet: UserWallet, quote: OnrampProviderWithQuote.Data, cryptoCurrency: CryptoCurrency, isDarkTheme: Boolean, ): Either { return Either.catch { val transaction = repository.getOnrampData( - userWalletId = userWalletId, + userWallet = userWallet, cryptoCurrency = cryptoCurrency, quote = quote, isDarkTheme = isDarkTheme, diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampStatusUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampStatusUseCase.kt index 20effbbeeb..92afbf647c 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampStatusUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampStatusUseCase.kt @@ -5,15 +5,16 @@ import com.tangem.domain.onramp.model.OnrampStatus 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.wallets.models.UserWallet class GetOnrampStatusUseCase( private val onrampRepository: OnrampRepository, private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(txId: String): Either { + suspend operator fun invoke(userWallet: UserWallet, txId: String): Either { return Either.catch { - onrampRepository.getStatus(txId) + onrampRepository.getStatus(userWallet = userWallet, txId = txId) }.mapLeft(errorResolver::resolve) } } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampTransactionUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampTransactionUseCase.kt index 32d3f258db..72ab25fe7c 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampTransactionUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampTransactionUseCase.kt @@ -11,10 +11,10 @@ class GetOnrampTransactionUseCase( private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(externalTxId: String): Either { + suspend operator fun invoke(txId: String): Either { return Either.catch { requireNotNull( - onrampTransactionRepository.getTransactionById(externalTxId), + onrampTransactionRepository.getTransactionById(txId), ) }.mapLeft(errorResolver::resolve) } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchPairsUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchPairsUseCase.kt index 1bbaf78853..2cc183e142 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchPairsUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchPairsUseCase.kt @@ -5,17 +5,23 @@ 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.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet class OnrampFetchPairsUseCase( private val repository: OnrampRepository, private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(cryptoCurrency: CryptoCurrency): Either { + suspend operator fun invoke(userWallet: UserWallet, cryptoCurrency: CryptoCurrency): Either { return Either.catch { val country = requireNotNull(repository.getDefaultCountrySync()) { "Country must not be null" } val currency = requireNotNull(repository.getDefaultCurrencySync()) { "Currency must not be null" } - repository.fetchPairs(currency = currency, country = country, cryptoCurrency = cryptoCurrency) + repository.fetchPairs( + userWallet = userWallet, + currency = currency, + country = country, + cryptoCurrency = cryptoCurrency, + ) }.mapLeft(errorResolver::resolve) } } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchQuotesUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchQuotesUseCase.kt index 1842f2a819..7135dbddb0 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchQuotesUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/OnrampFetchQuotesUseCase.kt @@ -5,14 +5,16 @@ import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet class OnrampFetchQuotesUseCase( private val repository: OnrampRepository, private val errorResolver: OnrampErrorResolver, ) { - suspend operator fun invoke(amount: Amount, cryptoCurrency: CryptoCurrency) = Either.catch { + suspend operator fun invoke(userWallet: UserWallet, amount: Amount, cryptoCurrency: CryptoCurrency) = Either.catch { repository.fetchQuotes( + userWallet = userWallet, cryptoCurrency = cryptoCurrency, amount = amount, ) 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 302a563b12..4e1dc066ae 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 @@ -4,7 +4,7 @@ import com.tangem.domain.onramp.model.* import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import kotlinx.coroutines.flow.Flow @Suppress("TooManyFunctions") @@ -13,15 +13,21 @@ interface OnrampRepository { fun getCurrencies(): Flow> fun getCountries(): Flow> suspend fun getCountriesSync(): List? - suspend fun getCountryByIp(): OnrampCountry - suspend fun getStatus(txId: String): OnrampStatus - suspend fun fetchCurrencies() - suspend fun fetchCountries(): List - suspend fun fetchPaymentMethodsIfAbsent() - suspend fun fetchPairs(currency: OnrampCurrency, country: OnrampCountry, cryptoCurrency: CryptoCurrency) - suspend fun fetchQuotes(cryptoCurrency: CryptoCurrency, amount: Amount) + suspend fun getCountryByIp(userWallet: UserWallet): OnrampCountry + suspend fun getStatus(userWallet: UserWallet, txId: String): OnrampStatus + suspend fun fetchCurrencies(userWallet: UserWallet) + suspend fun fetchCountries(userWallet: UserWallet): List + suspend fun fetchPaymentMethodsIfAbsent(userWallet: UserWallet) + suspend fun fetchPairs( + userWallet: UserWallet, + currency: OnrampCurrency, + country: OnrampCountry, + cryptoCurrency: CryptoCurrency, + ) + + suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) suspend fun getOnrampData( - userWalletId: UserWalletId, + userWallet: UserWallet, cryptoCurrency: CryptoCurrency, quote: OnrampProviderWithQuote.Data, isDarkTheme: Boolean, diff --git a/domain/quotes/.gitignore b/domain/quotes/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/quotes/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/quotes/build.gradle.kts b/domain/quotes/build.gradle.kts new file mode 100644 index 0000000000..8f7c2b07d7 --- /dev/null +++ b/domain/quotes/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + api(projects.domain.core) + api(projects.domain.tokens.models) +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt new file mode 100644 index 0000000000..347104dbfb --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.quotes + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote + +/** + * Quotes repository + * +[REDACTED_AUTHOR] + */ +interface QuotesRepositoryV2 { + + /** Get quotes by [currenciesIds] synchronously or null */ + suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set? +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt new file mode 100644 index 0000000000..4319ae8399 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.quotes.multi + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.tokens.model.CryptoCurrency + +/** + * Fetcher of quotes + * +[REDACTED_AUTHOR] + */ +interface MultiQuoteFetcher : FlowFetcher { + + /** + * Params + * + * @property currenciesIds identifiers of currencies + * @property appCurrencyId app currency id, if null then selected app currency will be used + */ + data class Params( + val currenciesIds: Set, + val appCurrencyId: String?, + ) +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteUpdater.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteUpdater.kt new file mode 100644 index 0000000000..ee490bac35 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteUpdater.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.quotes.multi + +/** + * Updater of quotes + * +[REDACTED_AUTHOR] + */ +interface MultiQuoteUpdater { + + /** Subscribe */ + fun subscribe() + + /** Unsubscribe */ + fun unsubscribe() +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt new file mode 100644 index 0000000000..7185ea9577 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote + +/** + * Producer of quote [CryptoCurrency.RawID] + * +[REDACTED_AUTHOR] + */ +interface SingleQuoteProducer : FlowProducer { + + data class Params(val rawCurrencyId: CryptoCurrency.RawID) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt new file mode 100644 index 0000000000..e65dbb492d --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.tokens.model.Quote + +/** + * Supplier of quote [SingleQuoteProducer.Params] + * + * @property factory factory for creating [SingleQuoteProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class SingleQuoteSupplier( + override val factory: SingleQuoteProducer.Factory, + override val keyCreator: (SingleQuoteProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingID.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingID.kt new file mode 100644 index 0000000000..6313b9277c --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingID.kt @@ -0,0 +1,4 @@ +package com.tangem.domain.staking.model + +// TODO: make part of YieldBalance in the future +data class StakingID(val integrationId: String, val address: String) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt index ce016aee04..fc57d56fce 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt @@ -1,6 +1,7 @@ package com.tangem.domain.staking.model.stakekit import com.tangem.domain.models.StatusSource +import com.tangem.domain.staking.model.StakingID import com.tangem.domain.staking.model.stakekit.action.StakingActionType import org.joda.time.DateTime import java.math.BigDecimal @@ -9,21 +10,41 @@ sealed class YieldBalance { abstract val integrationId: String? abstract val address: String? + abstract val source: StatusSource + + fun copySealed(source: StatusSource): YieldBalance { + return when (this) { + is Data -> copy(source = source) + is Empty -> copy(source = source) + is Error -> this + } + } + + fun getStakingId(): StakingID? { + val integrationId = integrationId + val address = address + + if (integrationId == null || address == null) return null + + return StakingID(integrationId = integrationId, address = address) + } data class Data( override val integrationId: String?, override val address: String, + override val source: StatusSource, val balance: YieldBalanceItem, - val source: StatusSource, ) : YieldBalance() data class Empty( override val integrationId: String?, override val address: String, - val source: StatusSource, + override val source: StatusSource, ) : YieldBalance() - data class Error(override val integrationId: String?, override val address: String?) : YieldBalance() + data class Error(override val integrationId: String?, override val address: String?) : YieldBalance() { + override val source: StatusSource = StatusSource.ACTUAL + } } data class YieldBalanceItem( diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt index 2d48d88f25..40a8c174a9 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt @@ -1,10 +1,7 @@ package com.tangem.domain.staking import arrow.core.Either -import com.tangem.domain.staking.model.stakekit.BalanceItem -import com.tangem.domain.staking.model.stakekit.BalanceType -import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.Token +import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionType import com.tangem.domain.staking.repositories.StakingErrorResolver @@ -51,6 +48,9 @@ class InvalidatePendingTransactionsUseCase( } StakingActionType.WITHDRAW -> { modifyBalancesByStatus(balances, action, BalanceType.UNSTAKED) + if (token.network == NetworkType.TON) { + modifyBalancesByStatus(balances, action, BalanceType.PREPARING) + } } StakingActionType.UNLOCK_LOCKED -> { modifyBalancesByStatus(balances, action, BalanceType.LOCKED) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/fetcher/YieldBalanceFetcherParams.kt b/domain/staking/src/main/java/com/tangem/domain/staking/fetcher/YieldBalanceFetcherParams.kt new file mode 100644 index 0000000000..ce15ede323 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/fetcher/YieldBalanceFetcherParams.kt @@ -0,0 +1,40 @@ +package com.tangem.domain.staking.fetcher + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Params for fetchers of yield balance + * +[REDACTED_AUTHOR] + */ +sealed interface YieldBalanceFetcherParams { + + /** User wallet ID */ + val userWalletId: UserWalletId + + /** + * Params for fetching multiple yield balances + * + * @property userWalletId user wallet ID + * @property currencyIdWithNetworkMap map of currency ID to network + */ + data class Multi( + override val userWalletId: UserWalletId, + val currencyIdWithNetworkMap: Map, + ) : YieldBalanceFetcherParams + + /** + * Params for fetching single yield balance + * + * @property userWalletId user wallet ID + * @property currencyId currency ID + * @property network network + */ + data class Single( + override val userWalletId: UserWalletId, + val currencyId: CryptoCurrency.ID, + val network: Network, + ) : YieldBalanceFetcherParams +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt new file mode 100644 index 0000000000..a189e8bcc9 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.staking.multi + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams + +/** + * Fetcher of yields balances + * +[REDACTED_AUTHOR] + */ +interface MultiYieldBalanceFetcher : FlowFetcher \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceProducer.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceProducer.kt new file mode 100644 index 0000000000..eaaff5ad56 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.staking.multi + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Producer of all yield balances for selected wallet [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface MultiYieldBalanceProducer : FlowProducer> { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceSupplier.kt new file mode 100644 index 0000000000..4c6cbed1f3 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceSupplier.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.staking.multi + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance + +/** + * Supplier of all yield balances for selected wallet [MultiYieldBalanceProducer.Params] + * + * @property factory factory for creating [MultiYieldBalanceProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class MultiYieldBalanceSupplier( + override val factory: FlowProducer.Factory, + override val keyCreator: (MultiYieldBalanceProducer.Params) -> String, +) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt index d97ac0f2b9..5f5da8eeb2 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt @@ -52,6 +52,11 @@ interface StakingRepository { fun getSingleYieldBalanceFlow(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Flow + suspend fun getSingleYieldBalanceSyncLegacy( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): YieldBalance + suspend fun getSingleYieldBalanceSync(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): YieldBalance suspend fun fetchMultiYieldBalance( @@ -65,10 +70,10 @@ interface StakingRepository { cryptoCurrencies: List, ): Flow - fun getMultiYieldBalanceUpdatesLegacy( + suspend fun getMultiYieldBalanceSyncLegacy( userWalletId: UserWalletId, cryptoCurrencies: List, - ): Flow + ): YieldBalanceList suspend fun getMultiYieldBalanceSync( userWalletId: UserWalletId, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt new file mode 100644 index 0000000000..1b81d24be3 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.staking.single + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams + +/** + * Fetcher of yield balance + * +[REDACTED_AUTHOR] + */ +interface SingleYieldBalanceFetcher : FlowFetcher \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceProducer.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceProducer.kt new file mode 100644 index 0000000000..6951cf3a1b --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceProducer.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.staking.single + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Producer of yield balance for selected wallet [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface SingleYieldBalanceProducer : FlowProducer { + + data class Params( + val userWalletId: UserWalletId, + val currencyId: CryptoCurrency.ID, + val network: Network, + ) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceSupplier.kt new file mode 100644 index 0000000000..f7509dc980 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceSupplier.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.staking.single + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance + +/** + * Supplier of yield balance for selected wallet [SingleYieldBalanceProducer.Params] + * + * @property factory factory for creating [SingleYieldBalanceProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class SingleYieldBalanceSupplier( + override val factory: FlowProducer.Factory, + override val keyCreator: (SingleYieldBalanceProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 6ba910ceae..f24fb5baf6 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(projects.domain.promo.models) implementation(projects.domain.promo) implementation(projects.domain.networks) + implementation(projects.domain.quotes) /** Project - Api */ implementation(projects.features.send.api) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt index ebfb2e7525..6e3d9532f0 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt @@ -7,6 +7,13 @@ sealed interface Quote { val rawCurrencyId: CryptoCurrency.RawID + fun copySealed(source: StatusSource): Quote { + return when (this) { + is Empty -> this + is Value -> copy(source = source) + } + } + /** * Represents unknown financial information for a specific cryptocurrency. * 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 56fc636d4f..e2354e1813 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 @@ -5,6 +5,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network @@ -12,6 +13,9 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope /** * A use case for adding multiple cryptocurrencies to a user's wallet. @@ -20,12 +24,14 @@ import com.tangem.domain.wallets.models.UserWalletId * network statuses, particularly after the addition of new tokens. */ // TODO: Add tests +@Suppress("LongParameterList") class AddCryptoCurrenciesUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -68,9 +74,14 @@ class AddCryptoCurrenciesUseCase( val currencyToAdd = currency.takeUnless(existingCurrencies::contains) ?: return@either addCurrencies(userWalletId, currencyToAdd) - refreshUpdatedNetworks(userWalletId, currencyToAdd, existingCurrencies) - refreshUpdatedYieldBalances(userWalletId, currencyToAdd) - refreshUpdatedQuotes(currencyToAdd) + + coroutineScope { + awaitAll( + async { refreshUpdatedNetworks(userWalletId, currencyToAdd, existingCurrencies) }, + async { refreshUpdatedYieldBalances(userWalletId, currencyToAdd) }, + async { refreshUpdatedQuotes(currencyToAdd) }, + ) + } } suspend operator fun invoke( @@ -94,9 +105,15 @@ class AddCryptoCurrenciesUseCase( } val tokenToAdd = createTokenCurrency(userWalletId, contractAddress, networkId) addCurrencies(userWalletId, tokenToAdd) - refreshUpdatedNetworks(userWalletId, tokenToAdd, existingCurrencies) - refreshUpdatedYieldBalances(userWalletId, tokenToAdd) - refreshUpdatedQuotes(tokenToAdd) + + coroutineScope { + awaitAll( + async { refreshUpdatedNetworks(userWalletId, tokenToAdd, existingCurrencies) }, + async { refreshUpdatedYieldBalances(userWalletId, tokenToAdd) }, + async { refreshUpdatedQuotes(tokenToAdd) }, + ) + } + tokenToAdd } @@ -150,10 +167,19 @@ class AddCryptoCurrenciesUseCase( } private suspend fun refreshUpdatedQuotes(currencyToAdd: CryptoCurrency) { - quotesRepository.fetchQuotes( - currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), - refresh = true, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), + appCurrencyId = null, + ), + ) + } else { + quotesRepository.fetchQuotes( + currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), + refresh = true, + ) + } } private suspend fun Raise.createTokenCurrency( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt index 2098e11d7b..20e19dfaf0 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt @@ -5,6 +5,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrency @@ -17,12 +18,14 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +@Suppress("LongParameterList") class FetchCardTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -90,10 +93,16 @@ class FetchCardTokenListUseCase( } private suspend fun fetchQuotes(currenciesIds: Set, refresh: Boolean) { - catch( - block = { quotesRepository.getQuotesSync(currenciesIds, refresh) }, - catch = { /* Ignore error */ }, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null), + ) + } else { + catch( + block = { quotesRepository.getQuotesSync(currenciesIds, refresh) }, + catch = { /* Ignore error */ }, + ) + } } private suspend fun fetchYieldBalances( 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 fa725b8c75..a38ae046d5 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 @@ -5,6 +5,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrency @@ -27,12 +28,14 @@ import kotlinx.coroutines.coroutineScope * @param quotesRepository The repository for retrieving cryptocurrency quotes. */ // TODO: Add tests +@Suppress("LongParameterList") class FetchCurrencyStatusUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -134,10 +137,19 @@ class FetchCurrencyStatusUseCase( } private suspend fun Raise.fetchQuote(currencyId: CryptoCurrency.ID, refresh: Boolean) { - catch( - block = { quotesRepository.getQuotesSync(setOfNotNull(currencyId.rawCurrencyId), refresh) }, - ) { - raise(CurrencyStatusError.DataError(it)) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = setOfNotNull(currencyId.rawCurrencyId), + appCurrencyId = null, + ), + ) + } else { + catch( + block = { quotesRepository.getQuotesSync(setOfNotNull(currencyId.rawCurrencyId), refresh) }, + ) { + raise(CurrencyStatusError.DataError(it)) + } } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt index 5344fecfd0..4602529c72 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt @@ -7,6 +7,7 @@ import arrow.core.raise.either import arrow.core.raise.ensureNotNull import arrow.core.toNonEmptyListOrNull import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrency @@ -29,12 +30,14 @@ import kotlinx.coroutines.coroutineScope * @param stakingRepository The repository for retrieving staking-related data. */ // TODO: Add tests +@Suppress("LongParameterList") class FetchTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -125,13 +128,22 @@ class FetchTokenListUseCase( } private suspend fun fetchQuotes(currenciesIds: Set, refresh: Boolean) { - catch( - block = { - val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() - quotesRepository.getQuotesSync(rawIds, refresh) - }, - ) { - /* Ignore error */ + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet(), + appCurrencyId = null, + ), + ) + } else { + catch( + block = { + val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() + quotesRepository.getQuotesSync(rawIds, refresh) + }, + ) { + /* Ignore error */ + } } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt new file mode 100644 index 0000000000..72b4d94cc4 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.tokens.error.CurrencyStatusError +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId + +class GetCryptoCurrenciesUseCase( + private val currenciesRepository: CurrenciesRepository, +) { + + /** + * Retrieves the list of cryptocurrencies within a multi-currency wallet. + * + * @param userWalletId The unique identifier of the user wallet. + * + * @return An [Either] representing success (Right) or an error (Left) in fetching the status. + */ + suspend operator fun invoke(userWalletId: UserWalletId): Either> { + return Either.catch { + currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) + }.mapLeft(CurrencyStatusError::DataError) + } +} \ 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 1688660d10..9ebfe0bced 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 @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.getOrElse import arrow.core.raise.catch import arrow.core.raise.either +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.tokens.error.QuotesError import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -16,6 +17,8 @@ import kotlinx.coroutines.coroutineScope class RefreshMultiCurrencyWalletQuotesUseCase( private val quotesRepository: QuotesRepository, private val currenciesRepository: CurrenciesRepository, + private val multiQuoteFetcher: MultiQuoteFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either { @@ -45,14 +48,23 @@ class RefreshMultiCurrencyWalletQuotesUseCase( } private suspend fun fetchQuotes(currenciesIds: Set) { - catch( - block = { - quotesRepository.fetchQuotes( + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( currenciesIds = currenciesIds.mapNotNullTo(hashSetOf(), CryptoCurrency.ID::rawCurrencyId), - refresh = true, - ) - }, - catch = { /* Ignore error */ }, - ) + appCurrencyId = null, + ), + ) + } else { + catch( + block = { + quotesRepository.fetchQuotes( + currenciesIds = currenciesIds.mapNotNullTo(hashSetOf(), CryptoCurrency.ID::rawCurrencyId), + refresh = true, + ) + }, + catch = { /* Ignore error */ }, + ) + } } } \ No newline at end of file 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 b48aaa3c4d..be40961b10 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 @@ -8,4 +8,8 @@ package com.tangem.domain.tokens interface TokensFeatureToggles { val isNetworksLoadingRefactoringEnabled: Boolean + + val isQuotesLoadingRefactoringEnabled: Boolean + + val isStakingLoadingRefactoringEnabled: Boolean } \ 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 ac008a8ac8..fed0750cf9 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 @@ -10,9 +10,14 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.* import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error @@ -37,10 +42,13 @@ import kotlinx.coroutines.flow.* abstract class BaseCurrencyStatusOperations( private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, + private val quotesRepositoryV2: QuotesRepositoryV2, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + private val singleQuoteSupplier: SingleQuoteSupplier, + private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -171,7 +179,16 @@ abstract class BaseCurrencyStatusOperations( } else { currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, cryptoCurrencyId) } - val quote = cryptoCurrencyId.rawCurrencyId?.let { quotesRepository.getQuoteSync(it) }?.right() + + val quote = cryptoCurrencyId.rawCurrencyId?.let { rawId -> + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + singleQuoteSupplier(params = SingleQuoteProducer.Params(rawCurrencyId = rawId)) + .firstOrNull() + } else { + quotesRepository.getQuoteSync(rawId) + } + } + ?.right() ?: Error.EmptyQuotes.left() val networkStatuses = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { @@ -237,7 +254,12 @@ abstract class BaseCurrencyStatusOperations( ?: return emptyList().right() val (networks, currenciesIds) = getIds(nonEmptyCurrencies) val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() - val quotes = quotesRepository.getQuotesSync(rawIds, false).right() + + val quotes = if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = rawIds)?.right() + } else { + quotesRepository.getQuotesSync(rawIds, false).right() + } val networkStatuses = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { multiNetworkStatusSupplier( @@ -268,13 +290,23 @@ abstract class BaseCurrencyStatusOperations( block = { currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId) }, catch = { raise(Error.DataError(it)) }, ) - val quotes = catch( - block = { - currency.id.rawCurrencyId?.let { quotesRepository.getQuoteSync(it) } - ?.right() ?: Error.EmptyQuotes.left() - }, - catch = { Error.DataError(it).left() }, - ) + + val quotes = if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + currency.id.rawCurrencyId?.let { + singleQuoteSupplier(params = SingleQuoteProducer.Params(rawCurrencyId = it)) + .firstOrNull() + } + ?.right() + ?: Error.EmptyQuotes.left() + } else { + catch( + block = { + currency.id.rawCurrencyId?.let { quotesRepository.getQuoteSync(it) } + ?.right() ?: Error.EmptyQuotes.left() + }, + catch = { Error.DataError(it).left() }, + ) + } val networkStatus = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { singleNetworkStatusSupplier( @@ -330,10 +362,18 @@ abstract class BaseCurrencyStatusOperations( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): EitherFlow { - return stakingRepository.getSingleYieldBalanceFlow( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrency, - ).map> { it.right() } + return if (tokensFeatureToggles.isStakingLoadingRefactoringEnabled) { + singleYieldBalanceSupplier( + params = SingleYieldBalanceProducer.Params( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ), + ) + } else { + stakingRepository.getSingleYieldBalanceFlow(userWalletId = userWalletId, cryptoCurrency = cryptoCurrency) + } + .map> { it.right() } .catch { emit(Error.DataError(it).left()) } .onEmpty { emit(Error.EmptyYieldBalances.left()) } } @@ -367,10 +407,18 @@ abstract class BaseCurrencyStatusOperations( ): Either { return catch( block = { - stakingRepository.getMultiYieldBalanceSync( - userWalletId, - cryptoCurrencies, - ).right() + if (tokensFeatureToggles.isStakingLoadingRefactoringEnabled) { + stakingRepository.getMultiYieldBalanceSync( + userWalletId = userWalletId, + cryptoCurrencies = cryptoCurrencies, + ) + } else { + stakingRepository.getMultiYieldBalanceSyncLegacy( + userWalletId = userWalletId, + cryptoCurrencies = cryptoCurrencies, + ) + } + .right() }, catch = { Error.EmptyYieldBalances.left() @@ -384,10 +432,12 @@ abstract class BaseCurrencyStatusOperations( ): Either { return catch( block = { - stakingRepository.getSingleYieldBalanceSync( - userWalletId, - cryptoCurrency, - ).right() + if (tokensFeatureToggles.isStakingLoadingRefactoringEnabled) { + stakingRepository.getSingleYieldBalanceSync(userWalletId, cryptoCurrency) + } else { + stakingRepository.getSingleYieldBalanceSyncLegacy(userWalletId, cryptoCurrency) + } + .right() }, catch = { Error.EmptyYieldBalances.left() 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 4fea4f5fbd..c222b79fec 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 @@ -14,9 +14,15 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.* @@ -34,20 +40,27 @@ import kotlinx.coroutines.flow.* class CachedCurrenciesStatusesOperations( private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, + quotesRepositoryV2: QuotesRepositoryV2, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, + private val singleQuoteSupplier: SingleQuoteSupplier, + private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, private val tokensFeatureToggles: TokensFeatureToggles, ) : BaseCurrenciesStatusesOperations, BaseCurrencyStatusOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + quotesRepositoryV2 = quotesRepositoryV2, networksRepository = networksRepository, stakingRepository = stakingRepository, multiNetworkStatusSupplier = multiNetworkStatusSupplier, singleNetworkStatusSupplier = singleNetworkStatusSupplier, + singleQuoteSupplier = singleQuoteSupplier, + singleYieldBalanceSupplier = singleYieldBalanceSupplier, tokensFeatureToggles = tokensFeatureToggles, ) { @@ -178,7 +191,14 @@ class CachedCurrenciesStatusesOperations( }, async { val rawCurrenciesIds = currenciesIds.mapNotNullTo(mutableSetOf()) { it.rawCurrencyId } - quotesRepository.fetchQuotes(rawCurrenciesIds) + + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params(currenciesIds = rawCurrenciesIds, appCurrencyId = null), + ) + } else { + quotesRepository.fetchQuotes(rawCurrenciesIds) + } }, async { stakingRepository.fetchMultiYieldBalance(userWalletId, currencies) }, ) @@ -249,27 +269,44 @@ class CachedCurrenciesStatusesOperations( } private fun getQuotes(tokensIds: NonEmptySet): Flow>> { - return quotesRepository.getQuotesUpdates(tokensIds.mapNotNull { it.rawCurrencyId }.toSet()) - .map, Either>> { it.right() } - .retryWhen { cause, _ -> - emit(TokenListError.DataError(cause).left()) - // adding delay before retry to avoid spam when flow restarted - delay(RETRY_DELAY) - true - } - .distinctUntilChanged() + return if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + getQuotesUpdates( + rawCurrencyIds = tokensIds.mapNotNullTo( + destination = hashSetOf(), + transform = CryptoCurrency.ID::rawCurrencyId, + ), + ) + } else { + quotesRepository.getQuotesUpdates(tokensIds.mapNotNull { it.rawCurrencyId }.toSet()) + .map, Either>> { it.right() } + .retryWhen { cause, _ -> + emit(TokenListError.DataError(cause).left()) + // adding delay before retry to avoid spam when flow restarted + delay(RETRY_DELAY) + true + } + .distinctUntilChanged() + } } override fun getQuotes(id: CryptoCurrency.RawID): Flow>> { - return quotesRepository.getQuotesUpdates(setOf(id)) - .map, Either>> { it.right() } - .retryWhen { cause, _ -> - emit(Error.DataError(cause).left()) - // adding delay before retry to avoid spam when flow restarted - delay(RETRY_DELAY) - true - } - .distinctUntilChanged() + return if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + singleQuoteSupplier( + params = SingleQuoteProducer.Params(rawCurrencyId = id), + ) + .map>> { setOf(it).right() } + .distinctUntilChanged() + } else { + quotesRepository.getQuotesUpdates(setOf(id)) + .map, Either>> { it.right() } + .retryWhen { cause, _ -> + emit(Error.DataError(cause).left()) + // adding delay before retry to avoid spam when flow restarted + delay(RETRY_DELAY) + true + } + .distinctUntilChanged() + } } override fun getNetworksStatuses( @@ -320,15 +357,19 @@ class CachedCurrenciesStatusesOperations( userWalletId: UserWalletId, cryptoCurrencies: List, ): EitherFlow { - return stakingRepository.getMultiYieldBalanceUpdates(userWalletId, cryptoCurrencies) - .map> { it.right() } - .retryWhen { cause, _ -> - emit(TokenListError.DataError(cause).left()) - // adding delay before retry to avoid spam when flow restarted - delay(RETRY_DELAY) - true - } - .distinctUntilChanged() + return if (tokensFeatureToggles.isStakingLoadingRefactoringEnabled) { + getYieldsBalancesUpdates(userWalletId, cryptoCurrencies) + } else { + stakingRepository.getMultiYieldBalanceUpdates(userWalletId, cryptoCurrencies) + .map> { it.right() } + .retryWhen { cause, _ -> + emit(TokenListError.DataError(cause).left()) + // adding delay before retry to avoid spam when flow restarted + delay(RETRY_DELAY) + true + } + .distinctUntilChanged() + } } // temporary code because token list is built using networks list @@ -363,6 +404,77 @@ class CachedCurrenciesStatusesOperations( .distinctUntilChanged() } + // temporary code because token list is built using networks list + private fun getQuotesUpdates(rawCurrencyIds: Set): EitherFlow> { + return channelFlow { + val state = MutableStateFlow(emptySet()) + + rawCurrencyIds.onEach { + launch { + singleQuoteSupplier( + params = SingleQuoteProducer.Params(rawCurrencyId = it), + ) + .onEach { quote -> + state.update { loadedStatuses -> + loadedStatuses.addOrReplace(quote) { it.rawCurrencyId == quote.rawCurrencyId } + } + } + .launchIn(scope = this) + } + } + + state + .onEach(::send) + .launchIn(scope = this) + } + .map, Either>> { it.right() } + .distinctUntilChanged() + } + + // temporary code because token list is built using networks list + private fun getYieldsBalancesUpdates( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): EitherFlow { + return channelFlow { + val state = MutableStateFlow(emptyList()) + + cryptoCurrencies.onEach { + launch { + singleYieldBalanceSupplier( + params = SingleYieldBalanceProducer.Params( + userWalletId = userWalletId, + currencyId = it.id, + network = it.network, + ), + ) + .onEach { balance -> + state.update { loadedBalances -> + loadedBalances.addOrReplace(balance) { + it.integrationId == balance.integrationId && it.address == balance.address + } + } + } + .launchIn(scope = this) + } + } + + state + .onEach(::send) + .launchIn(scope = this) + } + .map, Either> { balances -> + val yieldBalanceList = if (balances.isEmpty()) { + YieldBalanceList.Empty + } else { + YieldBalanceList.Data(balances) + } + + yieldBalanceList.right() + } + .distinctUntilChanged() + } + private fun isFetchingStarted(userWalletId: UserWalletId): Boolean { return fetchingState.value[userWalletId]?.let { it.isStarted() || it.isFinished() } ?: false } diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt index 3e5e99c917..e084456c8c 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt @@ -180,10 +180,16 @@ internal class GetPrimaryCurrencyStatusUpdatesUseCaseTest { tokensFeatureToggles = object : TokensFeatureToggles { override val isNetworksLoadingRefactoringEnabled: Boolean = false + override val isQuotesLoadingRefactoringEnabled: Boolean = false + override val isStakingLoadingRefactoringEnabled: Boolean = false }, singleNetworkStatusSupplier = mockk(), multiNetworkStatusFetcher = mockk(), multiNetworkStatusSupplier = mockk(), + multiQuoteFetcher = mockk(), + singleQuoteSupplier = mockk(), + quotesRepositoryV2 = mockk(), + singleYieldBalanceSupplier = mockk(), ), dispatchers = dispatchers, ) diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt index 04be38d010..51816af928 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt @@ -72,6 +72,11 @@ class MockStakingRepository : StakingRepository { send(YieldBalance.Error(integrationId = null, address = null)) } + override suspend fun getSingleYieldBalanceSyncLegacy( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): YieldBalance = YieldBalance.Error(integrationId = null, address = null) + override suspend fun getSingleYieldBalanceSync( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, @@ -96,10 +101,12 @@ class MockStakingRepository : StakingRepository { ) } - override fun getMultiYieldBalanceUpdatesLegacy( + override suspend fun getMultiYieldBalanceSyncLegacy( userWalletId: UserWalletId, cryptoCurrencies: List, - ): Flow = flowOf() + ): YieldBalanceList = YieldBalanceList.Data( + balances = listOf(YieldBalance.Error(integrationId = null, address = null)), + ) override suspend fun getMultiYieldBalanceSync( userWalletId: UserWalletId, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt index 61ac813b52..30ab51adf6 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt @@ -1,12 +1,12 @@ package com.tangem.domain.transaction import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionSendResult import com.tangem.blockchain.common.transaction.TransactionsSendResult import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network -import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.wallets.models.UserWalletId import java.math.BigDecimal import java.math.BigInteger @@ -22,7 +22,16 @@ interface TransactionRepository { userWalletId: UserWalletId, network: Network, txExtras: TransactionExtras?, - hash: String?, + ): TransactionData.Uncompiled + + @Suppress("LongParameterList") + suspend fun createTransferTransaction( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, ): TransactionData.Uncompiled @Suppress("LongParameterList") @@ -34,7 +43,6 @@ interface TransactionRepository { spenderAddress: String, userWalletId: UserWalletId, network: Network, - hash: String?, ): TransactionData.Uncompiled @Suppress("LongParameterList") @@ -45,9 +53,6 @@ interface TransactionRepository { destination: String, userWalletId: UserWalletId, network: Network, - isSwap: Boolean = false, - txExtras: TransactionExtras?, - hash: String? = null, ): Result suspend fun sendTransaction( @@ -66,9 +71,8 @@ interface TransactionRepository { ): com.tangem.blockchain.extensions.Result fun createTransactionDataExtras( - data: String, + callData: SmartContractCallData, network: Network, - transactionType: TransactionType, nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras @@ -78,4 +82,18 @@ interface TransactionRepository { cryptoCurrency: CryptoCurrency.Token, spenderAddress: String, ): BigDecimal + + suspend fun prepareForSend( + transactionData: TransactionData, + signer: TransactionSigner, + userWalletId: UserWalletId, + network: Network, + ): Result + + suspend fun prepareForSendMultiple( + transactionData: List, + signer: TransactionSigner, + userWalletId: UserWalletId, + network: Network, + ): Result> } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt index 65d69a9d0a..d3b136ff3e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt @@ -8,6 +8,9 @@ import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWalletId import java.math.BigDecimal +/** + * Use case to create and get approval transaction + */ class CreateApprovalTransactionUseCase( private val transactionRepository: TransactionRepository, ) { @@ -20,7 +23,6 @@ class CreateApprovalTransactionUseCase( fee: Fee, contractAddress: String, spenderAddress: String, - hash: String? = null, ) = Either.catch { transactionRepository.createApprovalTransaction( amount = BigDecimal.ZERO.convertToSdkAmount(cryptoCurrency), @@ -30,7 +32,6 @@ class CreateApprovalTransactionUseCase( userWalletId = userWalletId, network = cryptoCurrency.network, fee = fee, - hash = hash, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt index 52d686e403..b6d704cbbc 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt @@ -1,30 +1,51 @@ package com.tangem.domain.transaction.usecase -import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.common.extensions.hexToBytes import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository -import com.tangem.domain.transaction.models.TransactionType import java.math.BigInteger class CreateTransactionDataExtrasUseCase( private val transactionRepository: TransactionRepository, ) { + operator fun invoke(data: String, network: Network, gasLimit: BigInteger? = null, nonce: BigInteger? = null) = + either { + catch( + { + transactionRepository.createTransactionDataExtras( + callData = CompiledSmartContractCallData(data.hexToBytes()), + network = network, + nonce = nonce, + gasLimit = gasLimit, + ) + }, + ) { + raise(it) + } + } + operator fun invoke( - data: String, + callData: SmartContractCallData, network: Network, - transactionType: TransactionType, gasLimit: BigInteger? = null, nonce: BigInteger? = null, - ) = Either.catch { - requireNotNull( - transactionRepository.createTransactionDataExtras( - data = data, - network = network, - transactionType = transactionType, - nonce = nonce, - gasLimit = gasLimit, - ), - ) { "Failed to create transaction" } + ) = either { + catch( + { + transactionRepository.createTransactionDataExtras( + callData = callData, + network = network, + nonce = nonce, + gasLimit = gasLimit, + ) + }, + ) { + raise(it) + } } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt index 87f10ea630..a9d080eb2e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt @@ -8,6 +8,12 @@ import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.wallets.models.UserWalletId +/** + * Use case to create and get transaction + * + * !!!IMPORTANT + * Use when transaction data is already compiled by external service or provider + */ class CreateTransactionUseCase( private val transactionRepository: TransactionRepository, ) { @@ -24,7 +30,6 @@ class CreateTransactionUseCase( userWalletId: UserWalletId, network: Network, txExtras: TransactionExtras? = null, - hash: String? = null, ) = Either.catch { transactionRepository.createTransaction( amount = amount, @@ -34,7 +39,6 @@ class CreateTransactionUseCase( userWalletId = userWalletId, network = network, txExtras = txExtras, - hash = hash, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt new file mode 100644 index 0000000000..64457a93b8 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Use case to create and get transfer transaction + * + * !!!IMPORTANT + * Use when transaction data is compiled by us using BlockchainSDK methods + */ +class CreateTransferTransactionUseCase( + private val transactionRepository: TransactionRepository, +) { + + /** + * [REDACTED_TODO_COMMENT] + */ + @Suppress("LongParameterList") + suspend operator fun invoke( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, + ) = Either.catch { + transactionRepository.createTransferTransaction( + amount = amount, + fee = fee, + memo = memo, + destination = destination, + userWalletId = userWalletId, + network = network, + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt new file mode 100644 index 0000000000..d688ccbd9b --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt @@ -0,0 +1,128 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet +import java.math.BigInteger +import java.math.MathContext +import java.math.RoundingMode + +/** + * Use case to get transaction fee for ETH when we have gas from other services + * + */ +class GetEthSpecificFeeUseCase( + private val walletManagersFacade: WalletManagersFacade, +) { + suspend operator fun invoke( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + gasLimit: BigInteger, + gasPrice: BigInteger? = null, + ) = either { + catch( + block = { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + val gasPriceResult = gasPrice + ?: (walletManager as? EthereumWalletManager)?.getGasPriceValue() + ?: error("not supported for ${cryptoCurrency.network}") + + val blockchain = Blockchain.fromNetworkId(networkId = cryptoCurrency.network.backendId) + ?: error("unknown networkId ${cryptoCurrency.network.backendId}") + + val minimalFee = getEthLegacyFee( + gasPrice = gasPriceResult, + gasLimit = gasLimit, + decimals = cryptoCurrency.decimals, + blockchain = blockchain, + ) + + val normalGasPrice = gasPriceResult.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_NORMAL_FEE) + val normalFee = getEthLegacyFee( + gasPrice = normalGasPrice, + gasLimit = gasLimit, + decimals = cryptoCurrency.decimals, + blockchain = blockchain, + ) + + val priorityGasPrice = gasPriceResult.increaseBigIntegerByPercents( + MULTIPLIER_GAS_PRICE_FOR_PRIORITY_FEE, + ) + val priorityFee = getEthLegacyFee( + gasPrice = priorityGasPrice, + gasLimit = gasLimit, + decimals = cryptoCurrency.decimals, + blockchain = blockchain, + ) + + TransactionFee.Choosable( + minimum = minimalFee, + normal = normalFee, + priority = priorityFee, + ) + }, + catch = { + raise(GetFeeError.DataError(it)) + }, + ) + } + + private fun getEthLegacyFee( + gasPrice: BigInteger, + decimals: Int, + gasLimit: BigInteger, + blockchain: Blockchain, + ): Fee.Ethereum.Legacy { + val amount = Amount( + value = gasLimit.multiply(gasPrice).toBigDecimal( + scale = decimals, + mathContext = MathContext(decimals, RoundingMode.HALF_EVEN), + ), + blockchain = blockchain, + ) + return Fee.Ethereum.Legacy( + amount = amount, + gasLimit = gasLimit, + gasPrice = gasPrice, + ) + } + + private suspend fun EthereumWalletManager.getGasPriceValue(): BigInteger { + return when (val gasPrice = this.getGasPrice()) { + is Result.Failure -> throw gasPrice.error + is Result.Success -> gasPrice.data + } + } + + /** + * Increase big integer by percents + * + * @param percents in format 150 -> 50% + * @return increased value + */ + private fun BigInteger.increaseBigIntegerByPercents(percents: Int?): BigInteger { + return if (percents != null && percents != 0) { + this.multiply(percents.toBigInteger()).divide(BigInteger("100")) + } else { + this + } + } + + private companion object { + const val MULTIPLIER_GAS_PRICE_FOR_NORMAL_FEE = 150 // 50% + const val MULTIPLIER_GAS_PRICE_FOR_PRIORITY_FEE = 200 // 50% + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt index 548a43ab6c..ed841119c1 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt @@ -2,13 +2,12 @@ package com.tangem.domain.transaction.usecase import arrow.core.raise.catch import arrow.core.raise.either -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.* import com.tangem.blockchain.extensions.Result import com.tangem.domain.demo.DemoConfig import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.walletmanager.WalletManagersFacade @@ -17,11 +16,40 @@ import java.math.BigDecimal /** * Use case to get transaction fee + * + * !!!IMPORTANT!!! + * Use when transaction data is already compiled by external service or provider */ class GetFeeUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, ) { + suspend operator fun invoke(userWallet: UserWallet, network: Network, transactionData: TransactionData) = either { + catch( + block = { + val transactionSender = if (demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { + demoTransactionSender(userWallet, network) + } else { + walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) as? TransactionSender + } + val result = transactionSender?.getFee(transactionData = transactionData) + ?: error("Fee is null") + + val maybeFee = when (result) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + } + maybeFee + }, + catch = { + raise(GetFeeError.DataError(it)) + }, + ) + } + suspend operator fun invoke( amount: BigDecimal, destination: String, @@ -33,7 +61,7 @@ class GetFeeUseCase( val amountData = convertCryptoCurrencyToAmount(cryptoCurrency, amount) val result = if (demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { - demoTransactionSender(userWallet, cryptoCurrency).getFee( + demoTransactionSender(userWallet, cryptoCurrency.network).getFee( amount = amountData, destination = destination, ) @@ -58,13 +86,9 @@ class GetFeeUseCase( ) } - private suspend fun demoTransactionSender( - userWallet: UserWallet, - cryptoCurrency: CryptoCurrency, - ): DemoTransactionSender { + private suspend fun demoTransactionSender(userWallet: UserWallet, network: Network): DemoTransactionSender { return DemoTransactionSender( - walletManagersFacade - .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) + walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) ?: error("WalletManager is null"), ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetTransferFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetTransferFeeUseCase.kt new file mode 100644 index 0000000000..0a1582967d --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetTransferFeeUseCase.kt @@ -0,0 +1,102 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.demo.DemoConfig +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.mapToFeeError +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet +import java.math.BigDecimal + +/** + * Use case to get transfer transaction fee + * + * !!!IMPORTANT + * Use when transaction data is compiled by us using BlockchainSDK methods + */ +class GetTransferFeeUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, +) { + suspend operator fun invoke( + amount: BigDecimal, + destination: String, + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ) = either { + catch( + block = { + val amountData = convertCryptoCurrencyToAmount(cryptoCurrency, amount) + + val result = if (demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { + demoTransactionSender(userWallet, cryptoCurrency).getFee( + amount = amountData, + destination = destination, + ) + } else { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + val smartContractCallData = if (amountData.type is AmountType.Token) { + SmartContractCallDataProviderFactory.getTokenTransferCallData( + amount = amountData, + destinationAddress = destination, + blockchain = Blockchain.fromId(cryptoCurrency.network.id.value), + ) + } else { + null + } + + (walletManager as? TransactionSender)?.getFee( + amount = amountData, + destination = destination, + callData = smartContractCallData, + ) ?: error("Fee is null") + } + + val maybeFee = when (result) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + } + maybeFee + }, + catch = { + raise(GetFeeError.DataError(it)) + }, + ) + } + + private suspend fun demoTransactionSender( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) + ?: error("WalletManager is null"), + ) + } + + private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount( + currencySymbol = cryptoCurrency.symbol, + value = amount, + 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 diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt new file mode 100644 index 0000000000..b2e29146b2 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt @@ -0,0 +1,58 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.wallets.models.UserWallet + +class PrepareForSendUseCase( + private val transactionRepository: TransactionRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, +) { + suspend operator fun invoke( + transactionData: TransactionData, + userWallet: UserWallet, + network: Network, + ): Either { + val signer = createSigner(userWallet) + return transactionRepository.prepareForSend( + transactionData = transactionData, + userWalletId = userWallet.walletId, + network = network, + signer = signer, + ) + .fold(onSuccess = { it.right() }, onFailure = { it.left() }) + } + suspend operator fun invoke( + transactionData: List, + userWallet: UserWallet, + network: Network, + ): Either> { + val signer = createSigner(userWallet) + return transactionRepository.prepareForSendMultiple( + transactionData = transactionData, + userWalletId = userWallet.walletId, + network = network, + signer = signer, + ) + .fold(onSuccess = { it.right() }, onFailure = { it.left() }) + } + + private fun createSigner(userWallet: UserWallet): TransactionSigner { + 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), + ) + return signer + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt new file mode 100644 index 0000000000..d5496faf38 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt @@ -0,0 +1,40 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.common.CompletionResult +import com.tangem.common.core.TangemError +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet + +class SignUseCase( + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val walletManagersFacade: WalletManagersFacade, +) { + suspend operator fun invoke( + hash: ByteArray, + userWallet: UserWallet, + network: Network, + ): 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 not found") + + return when (val signResult = signer.sign(hash, walletManager.wallet.publicKey)) { + is CompletionResult.Failure -> signResult.error.left() + is CompletionResult.Success -> signResult.data.right() + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt index 61b2f26817..11b429be0f 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt @@ -21,8 +21,6 @@ class ValidateTransactionUseCase( destination: String, userWalletId: UserWalletId, network: Network, - isSwap: Boolean = false, - hash: String? = null, ): Either { return transactionRepository.validateTransaction( amount = amount, @@ -31,10 +29,6 @@ class ValidateTransactionUseCase( destination = destination, userWalletId = userWalletId, network = network, - isSwap = isSwap, - txExtras = null, - hash = hash, - ) - .fold(onSuccess = { Unit.right() }, onFailure = { it.left() }) + ).fold(onSuccess = { Unit.right() }, onFailure = { it.left() }) } } \ No newline at end of file diff --git a/domain/visa/build.gradle.kts b/domain/visa/build.gradle.kts index 8ce37bc6d1..dab90f1ecd 100644 --- a/domain/visa/build.gradle.kts +++ b/domain/visa/build.gradle.kts @@ -20,6 +20,9 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.appCurrency.models) + /** Security */ + implementation(deps.spongecastle.core) + /** Libs - Other */ implementation(deps.jodatime) implementation(deps.androidx.paging.runtime) diff --git a/domain/visa/models/build.gradle.kts b/domain/visa/models/build.gradle.kts index 0aafe4291a..2de3acff50 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -10,5 +10,6 @@ dependencies { ksp(deps.moshi.kotlin.codegen) implementation(deps.moshi.adapters) implementation(deps.kotlin.serialization) + implementation(deps.jodatime) implementation(projects.core.error) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt index f73861c111..bf9bd888dd 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt @@ -44,6 +44,7 @@ enum class VisaActivationError( FailedToCreateAddress(104003007), AddressNotMatched(104003008), InconsistentRemoteState(104003009), + FailedRemoteState(104003010), } object VisaAuthorizationAPIError : UniversalError { diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt index 78bbd9692b..0ee54c8be3 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.Serializable @JsonClass(generateAdapter = true) data class VisaActivationOrderInfo( @Json(name = "orderId") val orderId: String, - @Json(name = "customer_id") val customerId: String, - @Json(name = "customer_wallet_address") val customerWalletAddress: String, + @Json(name = "customerId") val customerId: String, + @Json(name = "customerWalletAddress") val customerWalletAddress: String, + @Json(name = "cardWalletAddress") val cardWalletAddress: String?, ) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt index 88e4389287..b34d72d2a6 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt @@ -1,6 +1,7 @@ package com.tangem.domain.visa.model import com.squareup.moshi.* +import com.tangem.domain.visa.model.VisaActivationRemoteState.* import kotlinx.serialization.Serializable @Serializable @@ -8,7 +9,7 @@ sealed class VisaActivationRemoteState { @Serializable data class CardWalletSignatureRequired( - val request: VisaCardWalletDataToSignRequest, + val activationOrderInfo: VisaActivationOrderInfo, ) : VisaActivationRemoteState() @Serializable @@ -20,9 +21,14 @@ sealed class VisaActivationRemoteState { data object PaymentAccountDeploying : VisaActivationRemoteState() @Serializable - data class WaitingPinCode( + data class AwaitingPinCode( val activationOrderInfo: VisaActivationOrderInfo, - ) : VisaActivationRemoteState() + val status: Status, + ) : VisaActivationRemoteState() { + enum class Status { + WaitingForPinCode, InProgress, WasError + } + } @Serializable data object WaitingForActivationFinishing : VisaActivationRemoteState() @@ -33,6 +39,9 @@ sealed class VisaActivationRemoteState { @Serializable data object BlockedForActivation : VisaActivationRemoteState() + @Serializable + data object Failed : VisaActivationRemoteState() + companion object { val jsonAdapter: VisaActivationRemoteState_JsonAdapter = VisaActivationRemoteState_JsonAdapter() } @@ -41,8 +50,8 @@ sealed class VisaActivationRemoteState { @Suppress("ClassNaming") class VisaActivationRemoteState_Json( @Json(name = "type") val type: VisaActivationRemoteState_Type, - @Json(name = "requestCardWallet") val requestCardWallet: VisaCardWalletDataToSignRequest? = null, @Json(name = "activationOrderInfo") val activationOrderInfo: VisaActivationOrderInfo? = null, + @Json(name = "awaiting_pin_code_status") val awaitingPinCodeStatus: AwaitingPinCodeStatus_Type? = null, ) @Suppress("ClassNaming") @@ -56,6 +65,13 @@ enum class VisaActivationRemoteState_Type { WaitingForActivationFinishing, Activated, BlockedForActivation, + Failed, +} + +@Suppress("ClassNaming") +@JsonClass(generateAdapter = false) +enum class AwaitingPinCodeStatus_Type { + WaitingForPinCode, InProgress, WasError } @Suppress("ClassNaming") @@ -65,46 +81,66 @@ class VisaActivationRemoteState_JsonAdapter { fun fromJson(value: VisaActivationRemoteState_Json): VisaActivationRemoteState { return when (value.type) { VisaActivationRemoteState_Type.CardWalletSignatureRequired -> - VisaActivationRemoteState.CardWalletSignatureRequired(value.requestCardWallet!!) + CardWalletSignatureRequired(value.activationOrderInfo!!) VisaActivationRemoteState_Type.CustomerWalletSignatureRequired -> - VisaActivationRemoteState.CustomerWalletSignatureRequired(value.activationOrderInfo!!) - VisaActivationRemoteState_Type.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying + CustomerWalletSignatureRequired(value.activationOrderInfo!!) + VisaActivationRemoteState_Type.PaymentAccountDeploying -> PaymentAccountDeploying VisaActivationRemoteState_Type.WaitingPinCode -> - VisaActivationRemoteState.WaitingPinCode(value.activationOrderInfo!!) + AwaitingPinCode( + activationOrderInfo = value.activationOrderInfo!!, + status = when (value.awaitingPinCodeStatus!!) { + AwaitingPinCodeStatus_Type.WaitingForPinCode -> + AwaitingPinCode.Status.WaitingForPinCode + AwaitingPinCodeStatus_Type.InProgress -> + AwaitingPinCode.Status.InProgress + AwaitingPinCodeStatus_Type.WasError -> + AwaitingPinCode.Status.WasError + }, + ) VisaActivationRemoteState_Type.WaitingForActivationFinishing -> - VisaActivationRemoteState.WaitingForActivationFinishing - VisaActivationRemoteState_Type.Activated -> VisaActivationRemoteState.Activated - VisaActivationRemoteState_Type.BlockedForActivation -> VisaActivationRemoteState.BlockedForActivation + WaitingForActivationFinishing + VisaActivationRemoteState_Type.Activated -> Activated + VisaActivationRemoteState_Type.BlockedForActivation -> BlockedForActivation + VisaActivationRemoteState_Type.Failed -> Failed } } @ToJson fun toJson(value: VisaActivationRemoteState): VisaActivationRemoteState_Json { return when (value) { - is VisaActivationRemoteState.CardWalletSignatureRequired -> + is CardWalletSignatureRequired -> VisaActivationRemoteState_Json( type = VisaActivationRemoteState_Type.CardWalletSignatureRequired, - requestCardWallet = value.request, + activationOrderInfo = value.activationOrderInfo, ) - is VisaActivationRemoteState.CustomerWalletSignatureRequired -> + is CustomerWalletSignatureRequired -> VisaActivationRemoteState_Json( type = VisaActivationRemoteState_Type.CustomerWalletSignatureRequired, activationOrderInfo = value.activationOrderInfo, ) - is VisaActivationRemoteState.PaymentAccountDeploying -> + is PaymentAccountDeploying -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.PaymentAccountDeploying) - is VisaActivationRemoteState.WaitingPinCode -> + is AwaitingPinCode -> VisaActivationRemoteState_Json( VisaActivationRemoteState_Type.WaitingPinCode, activationOrderInfo = value.activationOrderInfo, + awaitingPinCodeStatus = when (value.status) { + AwaitingPinCode.Status.WaitingForPinCode -> + AwaitingPinCodeStatus_Type.WaitingForPinCode + AwaitingPinCode.Status.InProgress -> + AwaitingPinCodeStatus_Type.InProgress + AwaitingPinCode.Status.WasError -> + AwaitingPinCodeStatus_Type.WasError + }, ) - is VisaActivationRemoteState.WaitingForActivationFinishing -> + is WaitingForActivationFinishing -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.WaitingForActivationFinishing) - is VisaActivationRemoteState.Activated -> VisaActivationRemoteState_Json( + is Activated -> VisaActivationRemoteState_Json( VisaActivationRemoteState_Type.Activated, ) - is VisaActivationRemoteState.BlockedForActivation -> + is BlockedForActivation -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.BlockedForActivation) + is Failed -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.Failed) } } } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthTokens.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthTokens.kt index b39a31f25b..32984786d7 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthTokens.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaAuthTokens.kt @@ -12,8 +12,15 @@ data class VisaAuthTokens( ) { @Serializable - @JvmInline - value class RefreshToken(val value: String) + @JsonClass(generateAdapter = true) + data class RefreshToken( + @Json(name = "value") val value: String, + @Json(name = "authType") val authType: Type, + ) { + enum class Type { + CardId, CardWallet, + } + } } fun VisaAuthTokens.getAuthHeader(): String { diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCardWalletDataToSignRequest.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCardWalletDataToSignRequest.kt index 831a4b1d6c..05f50c2d2b 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCardWalletDataToSignRequest.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCardWalletDataToSignRequest.kt @@ -1,3 +1,12 @@ package com.tangem.domain.visa.model -typealias VisaCardWalletDataToSignRequest = VisaActivationOrderInfo \ No newline at end of file +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable + +@Serializable +@JsonClass(generateAdapter = true) +data class VisaCardWalletDataToSignRequest( + @Json(name = "activationOrderInfo") val activationOrderInfo: VisaActivationOrderInfo, + @Json(name = "cardWalletAddress") val cardWalletAddress: String, +) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCustomerWalletDataToSignRequest.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCustomerWalletDataToSignRequest.kt index 0103e34df4..7532659cad 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCustomerWalletDataToSignRequest.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaCustomerWalletDataToSignRequest.kt @@ -9,4 +9,5 @@ import kotlinx.serialization.Serializable data class VisaCustomerWalletDataToSignRequest( @Json(name = "orderId") val orderId: String, @Json(name = "card_wallet_address") val cardWalletAddress: String, + @Json(name = "customer_wallet_address") val customerWalletAddress: String, ) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaDataToSignByCardWallet.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaDataToSignByCardWallet.kt index 9e95f713e1..aac9421715 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaDataToSignByCardWallet.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaDataToSignByCardWallet.kt @@ -8,10 +8,9 @@ data class VisaDataToSignByCardWallet( val hashToSign: String, ) -fun VisaDataToSignByCardWallet.sign(cardWalletAddress: String, rootOTP: String, otpCounter: Int, signature: String) = +fun VisaDataToSignByCardWallet.sign(rootOTP: String, otpCounter: Int, signature: String) = VisaSignedActivationDataByCardWallet( dataToSign = this, - cardWalletAddress = cardWalletAddress, rootOTP = rootOTP, otpCounter = otpCounter, signature = signature, diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaSignedActivationDataByCardWallet.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaSignedActivationDataByCardWallet.kt index 5b37c067ec..246767c013 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaSignedActivationDataByCardWallet.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaSignedActivationDataByCardWallet.kt @@ -2,7 +2,6 @@ package com.tangem.domain.visa.model class VisaSignedActivationDataByCardWallet( val dataToSign: VisaDataToSignByCardWallet, - val cardWalletAddress: String, val rootOTP: String, val otpCounter: Int, val signature: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt similarity index 100% rename from domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt rename to domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt index 6fda7af7df..c15d97e074 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt @@ -1,4 +1,5 @@ @file:Suppress("MaximumLineLength") + package com.tangem.domain.visa import android.util.Base64 @@ -14,14 +15,12 @@ import java.security.spec.X509EncodedKeySpec import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec private const val KEY_SIZE = 256 -class SetVisaPinCodeUseCase( - private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, -) { +class SetVisaPinCodeUseCase(private val visaActivationRepositoryFactory: VisaActivationRepository.Factory) { + @Suppress("MagicNumber") suspend operator fun invoke( visaCardId: VisaCardId, activationOrderId: String, @@ -29,19 +28,17 @@ class SetVisaPinCodeUseCase( ): Either = Either.catch { val visaActivationRepository = visaActivationRepositoryFactory.create(visaCardId) val rsaPublicKey = visaActivationRepository.getPinCodeRsaEncryptionPublicKey() + val formattedPin = "24$pinCode${"f".repeat(n = 8)}FF" val sessionKey = generateSessionKey() val sessionId = getSessionId(rsaPublicKey, sessionKey) - val secureRandom = SecureRandom() - val iv = ByteArray(size = 16) - secureRandom.nextBytes(iv) + val iv = generateIV() val ivParameterSpec = IvParameterSpec(iv) val ivPayload = Base64.encodeToString(iv, Base64.NO_WRAP) - val aesKey = SecretKeySpec(sessionKey.encoded, 0, sessionKey.encoded.size, "AES") - val aesCipher = Cipher.getInstance("AES/CBC/PKCS5PADDING") - aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, ivParameterSpec) - val encryptedPin = Base64.encodeToString(aesCipher.doFinal(pinCode.toByteArray()), Base64.NO_WRAP) + val aesCipher = Cipher.getInstance("AES/GCM/NoPadding") + aesCipher.init(Cipher.ENCRYPT_MODE, sessionKey, ivParameterSpec) + val encryptedPin = Base64.encodeToString(aesCipher.doFinal(formattedPin.encodeToByteArray()), Base64.NO_WRAP) visaActivationRepository.sendPinCode( VisaEncryptedPinCode( @@ -59,6 +56,12 @@ class SetVisaPinCodeUseCase( return generator.generateKey() } + private fun generateIV(): ByteArray { + val iv = ByteArray(size = 16) + SecureRandom().nextBytes(iv) + return iv + } + private fun getPublicKey(rsaPublicKey: String): PublicKey { val keyBytes = Base64.decode(rsaPublicKey, Base64.NO_WRAP) val spec = X509EncodedKeySpec(keyBytes) @@ -67,8 +70,9 @@ class SetVisaPinCodeUseCase( private fun getSessionId(rsaPublicKey: String, sessionKey: Key): String { val publicKey = getPublicKey(rsaPublicKey) - val cipher = Cipher.getInstance("RSA") + val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") cipher.init(Cipher.ENCRYPT_MODE, publicKey) - return Base64.encodeToString(cipher.doFinal(sessionKey.encoded), Base64.NO_WRAP) + val base64SessionKey = Base64.encodeToString(sessionKey.encoded, Base64.NO_WRAP) + return Base64.encodeToString(cipher.doFinal(base64SessionKey.encodeToByteArray()), Base64.NO_WRAP) } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaActivationRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaActivationRepository.kt index f6d451fb7b..339f8c7e9c 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaActivationRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/repository/VisaActivationRepository.kt @@ -6,8 +6,6 @@ interface VisaActivationRepository { suspend fun getActivationRemoteState(): VisaActivationRemoteState - suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState - suspend fun getCardWalletAcceptanceData(request: VisaCardWalletDataToSignRequest): VisaDataToSignByCardWallet suspend fun getCustomerWalletAcceptanceData( diff --git a/domain/wallet-connect/build.gradle.kts b/domain/wallet-connect/build.gradle.kts index 9b0cbaa2e3..a2178f32a7 100644 --- a/domain/wallet-connect/build.gradle.kts +++ b/domain/wallet-connect/build.gradle.kts @@ -1,14 +1,24 @@ plugins { - alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) id("configuration") } +android { + namespace = "com.tangem.domain.walletconnect" +} + dependencies { /* Project - Domain */ implementation(projects.domain.core) + implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) implementation(projects.domain.walletConnect.models) + implementation(projects.domain.blockaid.models) /* Other */ implementation(deps.moshi.adapters) + + /* Tangem libraries */ + implementation(tangemDeps.blockchain) } \ No newline at end of file diff --git a/domain/wallet-connect/models/build.gradle.kts b/domain/wallet-connect/models/build.gradle.kts index c471057d7c..4fd593d3d4 100644 --- a/domain/wallet-connect/models/build.gradle.kts +++ b/domain/wallet-connect/models/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { /* Domain */ implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.blockaid.models) /* Other */ implementation(deps.moshi) diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt new file mode 100644 index 0000000000..15d22104ba --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.walletconnect.model + +sealed interface WcEthMethod : WcMethod { + + data class MessageSign( + val message: String, + val account: String, + ) : WcEthMethod + + data class SendTransaction( + val transaction: WcEthTransactionParams, + ) : WcEthMethod + + data class SignTransaction( + val transaction: WcEthTransactionParams, + ) : WcEthMethod +} \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt new file mode 100644 index 0000000000..cdde8975d6 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.walletconnect.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WcEthTransactionParams( + @Json(name = "from") + val from: String, + + @Json(name = "to") + val to: String?, + + @Json(name = "data") + val data: String, + + @Json(name = "gas") + val gas: String?, + + @Json(name = "gasPrice") + val gasPrice: String?, + + @Json(name = "value") + val value: String?, + + @Json(name = "nonce") + val nonce: String?, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt index 05c3df4d98..fafbb31a81 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt @@ -1,5 +1,5 @@ package com.tangem.domain.walletconnect.model -interface WcMethod { +sealed interface WcMethod { data object Unsupported : WcMethod } \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcNetwork.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcNetwork.kt deleted file mode 100644 index 1ef5352c31..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcNetwork.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.domain.walletconnect.model - -import com.tangem.domain.tokens.model.Network - -sealed interface WcNetwork { - - val name: String - - data class Supported(val network: Network) : WcNetwork { - override val name: String get() = network.name - } - - data class Unknown(override val name: String) : WcNetwork -} \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt index 8f90d64512..5a060eaaba 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt @@ -4,7 +4,7 @@ sealed class WcPairError(override val message: String) : Exception(message) { data object UnsupportedDApp : WcPairError("UnsupportedDApp") data class UnsupportedNetworks( - val chains: Set, + val chains: Set, ) : WcPairError("ApprovalErrorMissingNetworks") data class ExternalApprovalError( diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt index 92501d8bba..d3707d52f6 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt @@ -1,9 +1,11 @@ package com.tangem.domain.walletconnect.model +import com.domain.blockaid.models.dapp.CheckDAppResult import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet data class WcSession( - val userWalletId: UserWalletId, + val wallet: UserWallet, val sdkModel: WcSdkSession, + val securityStatus: CheckDAppResult, ) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt index 06f4291649..427c4a9d89 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt @@ -1,8 +1,9 @@ package com.tangem.domain.walletconnect.model -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet data class WcSessionApprove( - val walletId: UserWalletId, - val network: List, + val wallet: UserWallet, + val network: List, ) \ No newline at end of file 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 63a946b9c6..5698f8421b 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 @@ -1,5 +1,6 @@ package com.tangem.domain.walletconnect.model +import com.domain.blockaid.models.dapp.CheckDAppResult import com.squareup.moshi.JsonClass import com.tangem.domain.wallets.models.UserWalletId @@ -7,4 +8,5 @@ import com.tangem.domain.wallets.models.UserWalletId data class WcSessionDTO( val topic: String, val walletId: UserWalletId, + val securityStatus: CheckDAppResult, ) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt index 648163817b..4a110229c4 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt @@ -1,19 +1,21 @@ package com.tangem.domain.walletconnect.model +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.tangem.domain.tokens.model.Network import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet data class WcSessionProposal( val dAppMetaData: WcAppMetaData, - val proposalNetwork: Map, - val securityStatus: Any, + val proposalNetwork: Map, + val securityStatus: CheckDAppResult, ) { data class ProposalNetwork( - val walletId: UserWalletId, - val missingRequired: Set, - val required: Set, - val available: Set, - val notAdded: Set, + val wallet: UserWallet, + val missingRequired: Set, + val required: Set, + val available: Set, + val notAdded: Set, ) } \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSolanaMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSolanaMethod.kt new file mode 100644 index 0000000000..3bc1f2dfbc --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSolanaMethod.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.walletconnect.model + +sealed interface WcSolanaMethod : WcMethod { + + data class SignMessage( + val pubKey: String, + val message: String, + ) : WcSolanaMethod + + data class SignTransaction( + val transaction: String, + ) : WcSolanaMethod + + data class SignAllTransaction( + val transaction: List, + ) : WcSolanaMethod +} \ 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 6c2a996c91..fa207f0263 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 @@ -2,12 +2,12 @@ package com.tangem.domain.walletconnect.repository import arrow.core.Either import com.tangem.domain.walletconnect.model.WcSession -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import kotlinx.coroutines.flow.Flow interface WcSessionsManager { - val sessions: Flow>> - suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) - suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either + 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/request/WcRequestService.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt deleted file mode 100644 index 5918accfd9..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.domain.walletconnect.request - -import com.tangem.domain.walletconnect.model.WcRequest -import kotlinx.coroutines.flow.Flow - -interface WcRequestService { - val requests: Flow> -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt index 0adbf1dff6..138c4a419f 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt @@ -2,16 +2,16 @@ package com.tangem.domain.walletconnect.usecase import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.repository.WcSessionsManager -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first class WcSessionsUseCase(private val sessionsManager: WcSessionsManager) { - operator fun invoke(): Flow>> { + operator fun invoke(): Flow>> { return sessionsManager.sessions } - suspend fun invokeSync(): Map> { + suspend fun invokeSync(): Map> { return sessionsManager.sessions.first() } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSimpleSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSimpleSignUseCase.kt deleted file mode 100644 index e0bb5239a4..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSimpleSignUseCase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.domain.walletconnect.usecase - -import arrow.core.Either -import kotlinx.coroutines.flow.Flow - -interface WcSimpleSignUseCase : WcUseCase { - - fun signFlow(initModel: SignModel): Flow> - - fun action(action: MiddleAction) - - fun cancel() - fun sign(toSign: SignModel) - - sealed interface State { - val model: SignModel - - data class PreSign(override val model: SignModel) : State - data class Signing(override val model: SignModel) : State - data class Result( - val result: Either, - override val model: SignModel, - ) : State - } -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt index a2efe639cd..fbe655766a 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt @@ -1,3 +1,18 @@ package com.tangem.domain.walletconnect.usecase -interface WcUseCase \ No newline at end of file +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest + +interface WcUseCase + +interface WcMethodUseCase : WcUseCase { + val session: WcSession + val rawSdkRequest: WcSdkSessionRequest + val network: Network +} + +interface WcMutableFee { + fun updateFee(fee: Fee) +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt index a34bd8e9f6..707dc20180 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt @@ -25,6 +25,6 @@ class WcDisconnectUseCase( } suspend fun disconnect(session: WcSession) { - sessionsManager.removeSession(session.userWalletId, session) + sessionsManager.removeSession(session) } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/EthPersonalSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/EthPersonalSignUseCase.kt deleted file mode 100644 index 5e86f84006..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/EthPersonalSignUseCase.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.tangem.domain.walletconnect.usecase.ethereum - -import arrow.core.Either -import com.tangem.domain.walletconnect.model.WcRequest -import com.tangem.domain.walletconnect.respond.WcRespondService -import com.tangem.domain.walletconnect.usecase.WcUseCase -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* - -class EthPersonalSignUseCase( - private val wcRequest: WcRequest, - private val respondService: WcRespondService, -) : WcUseCase { - - private val onCallTerminalAction = Channel() - - fun signFlow(): Flow = flow { - val model = SignModel(wcRequest.method.raw) - emit(State.PreSign(model)) - - when (val action = onCallTerminalAction.receiveAsFlow().first()) { - TerminalAction.Cancel -> { - val result = respondService.rejectRequest(wcRequest.rawSdkRequest) - emit(State.Result(result, model)) - } - is TerminalAction.Sign -> { - emit(State.Signing(action.toSign)) - val signed = signAndPrepareForSend() - val result = - if (signed != null) { - respondService.respond(wcRequest.rawSdkRequest, signed) - } else { - respondService.rejectRequest(wcRequest.rawSdkRequest) - } - emit(State.Result(result, model)) - } - } - } - - @Suppress("FunctionOnlyReturningConstant") // todo(wc) remove later - private suspend fun signAndPrepareForSend(): String? { - return null // todo(wc) - } - - fun sign(toSign: SignModel) { - onCallTerminalAction.trySend(TerminalAction.Sign(toSign)) - } - - fun cancel() { - onCallTerminalAction.trySend(TerminalAction.Cancel) - } - - sealed interface TerminalAction { - data class Sign(val toSign: SignModel) : TerminalAction - data object Cancel : TerminalAction - } - - sealed interface State { - val model: SignModel - - data class PreSign(override val model: SignModel) : State - data class Signing(override val model: SignModel) : State - data class Result( - val result: Either, - override val model: SignModel, - ) : State - } - - data class SignModel( - val raw: List, - ) -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMessageSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMessageSignUseCase.kt new file mode 100644 index 0000000000..0895241c36 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMessageSignUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.walletconnect.usecase.ethereum + +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase + +interface WcEthMessageSignUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun { + + data class SignModel( + val humanMsg: String, + val account: String, + val rawMsg: String, + ) +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt deleted file mode 100644 index cf40981044..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.domain.walletconnect.usecase.ethereum - -import com.tangem.domain.walletconnect.model.WcMethod - -sealed interface WcEthMethod : WcMethod { - - data class SignMessage( - val raw: List, - ) : WcEthMethod -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthTransactionUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthTransactionUseCase.kt new file mode 100644 index 0000000000..48f74153de --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthTransactionUseCase.kt @@ -0,0 +1,42 @@ +package com.tangem.domain.walletconnect.usecase.ethereum + +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.WcMutableFee +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase + +interface WcEthSendTransactionUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun, + WcMutableFee { + + override val session: WcSession + override val rawSdkRequest: WcSdkSessionRequest + override val network: Network + + override fun sign() + override fun cancel() + override fun updateFee(fee: Fee) +} + +interface WcEthSignTransactionUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun, + WcMutableFee { + + override val session: WcSession + override val rawSdkRequest: WcSdkSessionRequest + override val network: Network + + override fun sign() + override fun cancel() + override fun updateFee(fee: Fee) +} + +data class WcEthTransaction( + val dAppFee: Fee.Ethereum.Legacy?, + val transactionData: TransactionData.Uncompiled, +) \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairState.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairState.kt deleted file mode 100644 index 07edd2b5f8..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairState.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.domain.walletconnect.usecase.pair - -import arrow.core.Either -import com.tangem.domain.walletconnect.model.WcPairError -import com.tangem.domain.walletconnect.model.WcSession -import com.tangem.domain.walletconnect.model.WcSessionApprove -import com.tangem.domain.walletconnect.model.WcSessionProposal - -sealed interface WcPairState { - data object Loading : WcPairState - data class Error(val error: WcPairError) : WcPairState - data class Proposal(val dAppSession: WcSessionProposal) : WcPairState - - sealed interface Approving : WcPairState { - val session: WcSessionApprove - - data class Loading(override val session: WcSessionApprove) : Approving - data class Result( - override val session: WcSessionApprove, - val result: Either, - ) : Approving - } -} \ 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 2de6663dbd..b926213140 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 @@ -1,6 +1,10 @@ package com.tangem.domain.walletconnect.usecase.pair +import arrow.core.Either +import com.tangem.domain.walletconnect.model.WcPairError +import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionApprove +import com.tangem.domain.walletconnect.model.WcSessionProposal import kotlinx.coroutines.flow.Flow interface WcPairUseCase { @@ -11,4 +15,20 @@ interface WcPairUseCase { fun reject() enum class Source { QR, DEEPLINK, CLIPBOARD, ETC } +} + +sealed interface WcPairState { + data object Loading : WcPairState + data class Error(val error: WcPairError) : WcPairState + data class Proposal(val dAppSession: WcSessionProposal) : WcPairState + + sealed interface Approving : WcPairState { + val session: WcSessionApprove + + data class Loading(override val session: WcSessionApprove) : Approving + data class Result( + override val session: WcSessionApprove, + val result: Either, + ) : Approving + } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt new file mode 100644 index 0000000000..f011b82087 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt @@ -0,0 +1,30 @@ +package com.tangem.domain.walletconnect.usecase.sign + +import arrow.core.Either +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import kotlinx.coroutines.flow.Flow + +interface WcSignUseCase : WcMethodUseCase { + + fun cancel() + fun sign() + + interface SimpleRun { + operator fun invoke(): Flow> + } + + interface ArgsRun { + operator fun invoke(args: Args): Flow> + } +} + +data class WcSignState( + val signModel: SignModel, + val domainStep: WcSignStep, +) + +sealed interface WcSignStep { + data object PreSign : WcSignStep + data object Signing : WcSignStep + data class Result(val result: Either) : WcSignStep +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/solana/WcSolanaSignTransactionUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/solana/WcSolanaSignTransactionUseCase.kt new file mode 100644 index 0000000000..92eb17320f --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/solana/WcSolanaSignTransactionUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.walletconnect.usecase.solana + +import com.tangem.blockchain.common.TransactionData +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase +import kotlinx.coroutines.flow.Flow + +interface WcSolanaSignTransactionUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun { + + override val network: Network + override val rawSdkRequest: WcSdkSessionRequest + override val session: WcSession + + override fun sign() + override fun cancel() + override fun invoke(): Flow> +} + +interface WcSolanaSignAllTransactionUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun> { + + override val network: Network + override val rawSdkRequest: WcSdkSessionRequest + override val session: WcSession + + override fun sign() + override fun cancel() + override fun invoke(): Flow>> +} \ No newline at end of file diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt index 95b44fff95..df15cfd819 100644 --- a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt @@ -10,7 +10,6 @@ import com.tangem.domain.models.scan.ScanResponse * * @property name User wallet name * @property walletId User wallet [UserWalletId] - * @property artworkUrl User wallet card artwork URL * @property cardsInWallet List of cards IDs assigned with this user's wallet. The list will be empty if the wallet * has been backed up on another device. * @property isMultiCurrency Indicates whether this user wallet can work with more than one currency @@ -22,8 +21,6 @@ data class UserWallet( val name: String, @Json(name = "walletId") val walletId: UserWalletId, - @Json(name = "artworkUrl") - val artworkUrl: String, @Json(name = "cardsInWallet") val cardsInWallet: Set, @Json(name = "isMultiCurrency") diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt index dfaec8126c..7053d98d7d 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt @@ -5,7 +5,6 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase -import com.tangem.domain.wallets.usecase.GetCardImageUseCase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -13,7 +12,6 @@ import dagger.assisted.AssistedInject class UserWalletBuilder @AssistedInject constructor( @Assisted private val scanResponse: ScanResponse, private val generateWalletNameUseCase: GenerateWalletNameUseCase, - private val getCardImageUseCase: GetCardImageUseCase, ) { private var backupCardsIds: Set = emptySet() private var hasBackupError: Boolean = false @@ -38,7 +36,7 @@ class UserWalletBuilder @AssistedInject constructor( this.hasBackupError = hasBackupError } - suspend fun build(): UserWallet? { + fun build(): UserWallet? { return with(scanResponse) { UserWalletIdBuilder.scanResponse(scanResponse) .build() @@ -50,7 +48,6 @@ class UserWalletBuilder @AssistedInject constructor( isBackupNotAllowed = card.isBackupNotAllowed, isStartToCoin = cardTypesResolver.isStart2Coin(), ), - artworkUrl = getCardImageUseCase.invoke(card.cardId, card.cardPublicKey), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt index b44e440301..048729f9e9 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt @@ -4,8 +4,12 @@ import com.tangem.common.extensions.toHexString import com.tangem.common.services.Result import com.tangem.domain.common.TwinCardNumber import com.tangem.domain.common.TwinsHelper +import com.tangem.domain.models.ArtworkModel import com.tangem.domain.wallets.models.Artwork +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles /** * Use case for getting card image url @@ -14,7 +18,11 @@ import com.tangem.operations.attestation.OnlineCardVerifier * [REDACTED_AUTHOR] */ -class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { +class GetCardImageUseCase( + private val verifier: OnlineCardVerifier, + private val cardArtworksProvider: CardArtworksProvider, + private val cardSdkFeatureToggles: CardSdkFeatureToggles, +) { /** * Get card image url @@ -22,14 +30,30 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { * @param cardId card id * @param cardPublicKey card public key */ - suspend operator fun invoke(cardId: String, cardPublicKey: ByteArray): String { + suspend operator fun invoke(cardId: String, cardPublicKey: ByteArray, size: ArtworkSize): ArtworkModel { + return if (cardSdkFeatureToggles.isNewArtworkLoadingEnabled) { + val result = cardArtworksProvider.getArtwork( + cardId = cardId, + cardPublicKey = cardPublicKey, + size = size, + ) + when (result) { + is Result.Failure -> ArtworkModel(null, getFallbackArtworkUrl(cardId)) + is Result.Success -> ArtworkModel(result.data, getFallbackArtworkUrl(cardId)) + } + } else { + ArtworkModel(null, getLegacyArtwork(cardId, cardPublicKey)) + } + } + + private suspend fun getLegacyArtwork(cardId: String, cardPublicKey: ByteArray): String { return when (val result = verifier.getCardInfo(cardId, cardPublicKey)) { is Result.Success -> { val artworkId = result.data.artwork?.id if (artworkId.isNullOrEmpty()) { getFallbackArtworkUrl(cardId) } else { - OnlineCardVerifier.getUrlForArtwork( + CardArtworksProvider.getUrlForArtwork( cardId = cardId, cardPublicKey = cardPublicKey.toHexString(), artworkId = artworkId, @@ -41,8 +65,6 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { } } - fun getDefaultFallbackUrl(): String = Artwork.DEFAULT_IMG_URL - private fun getFallbackArtworkUrl(cardId: String): String { return when { cardId.startsWith(Artwork.SERGIO_CARD_ID) -> Artwork.SERGIO_CARD_URL @@ -50,7 +72,7 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { else -> when (TwinsHelper.getTwinCardNumber(cardId)) { TwinCardNumber.First -> Artwork.TWIN_CARD_1_URL TwinCardNumber.Second -> Artwork.TWIN_CARD_2_URL - else -> getDefaultFallbackUrl() + else -> Artwork.DEFAULT_IMG_URL } } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 9a4ec03248..37abe05472 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.navigation.url.DummyUrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsUM @@ -28,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent { val previewState = DetailsUM( items = previewBlocks, footer = previewFooter, + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = { /* no-op */ }, ) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt index d9bdebec63..7d8f07f26d 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt @@ -29,7 +29,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("My Wallet"), information = getInformation(cardCount = 1), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, onClick = {}, ), @@ -38,7 +37,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("Old wallet"), information = getInformation(cardCount = 2), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, onClick = {}, ), @@ -47,7 +45,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("Multi Card"), information = getInformation(cardCount = 3), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = false, onClick = {}, ), diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt index 15f0fead96..b7c390e3bd 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt @@ -1,9 +1,11 @@ package com.tangem.features.details.entity +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import kotlinx.collections.immutable.ImmutableList internal data class DetailsUM( val items: ImmutableList, val footer: DetailsFooterUM, + val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig, val popBack: () -> Unit, ) \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt new file mode 100644 index 0000000000..18363c2c30 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt @@ -0,0 +1,16 @@ +package com.tangem.features.details.entity + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.details.impl.R + +internal data class SelectEmailFeedbackTypeBS( + val onOptionClick: (Option) -> Unit, +) : TangemBottomSheetConfigContent { + + enum class Option(val text: TextReference) { + General(resourceReference(R.string.common_contact_tangem_support)), + Visa(resourceReference(R.string.common_contact_visa_support)), + } +} \ No newline at end of file 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 4ccf6cdd01..c659204216 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 @@ -7,17 +7,22 @@ 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.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.redux.LegacyAction import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsUM +import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -47,6 +52,7 @@ internal class DetailsModel @Inject constructor( private val appStateHolder: ReduxStateHolder, private val getCardInfoUseCase: GetCardInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val getWalletsUseCase: GetWalletsUseCase, override val dispatchers: CoroutineDispatcherProvider, ) : Model() { @@ -84,6 +90,7 @@ internal class DetailsModel @Inject constructor( socials = socialsBuilder.buildAll(), appVersion = getAppVersion(), ), + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = router::pop, ), ) @@ -99,12 +106,88 @@ internal class DetailsModel @Inject constructor( private fun sendFeedback() { modelScope.launch { + val userWallets = getWalletsUseCase.invokeSync() + val scanResponse = getSelectedWalletSyncUseCase().getOrNull()?.scanResponse ?: error("Selected wallet is null") val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(cardInfo = cardInfo)) + val feedbackType = when { + userWallets.all { it.scanResponse.card.isVisa } -> FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + userWallets.all { it.scanResponse.card.isVisa.not() } -> FeedbackEmailType.DirectUserRequest(cardInfo) + else -> { + showFeedbackEmailTypeOptionBS(cardInfo) + return@launch + } + } + + sendFeedbackEmailUseCase(feedbackType) + } + } + + private fun showFeedbackEmailTypeOptionBS(selectedCardInfo: CardInfo) { + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = + it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + ) + } + }, + content = SelectEmailFeedbackTypeBS( + onOptionClick = { option -> + onEmailFeedbackTypeOptionSelected( + selectedCardInfo = selectedCardInfo, + option = option, + ) + + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = + it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + ) + } + }, + ), + ), + ) + } + } + + private fun onEmailFeedbackTypeOptionSelected( + selectedCardInfo: CardInfo, + option: SelectEmailFeedbackTypeBS.Option, + ) { + modelScope.launch { + val feedbackType = when (option) { + SelectEmailFeedbackTypeBS.Option.General -> { + if (selectedCardInfo.isVisa.not()) { + FeedbackEmailType.DirectUserRequest(selectedCardInfo) + } else { + val scanResponse = getWalletsUseCase.invokeSync() + .firstOrNull { it.scanResponse.card.isVisa.not() }?.scanResponse ?: return@launch + val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + FeedbackEmailType.DirectUserRequest(cardInfo) + } + } + SelectEmailFeedbackTypeBS.Option.Visa -> { + if (selectedCardInfo.isVisa) { + FeedbackEmailType.Visa.DirectUserRequest(selectedCardInfo) + } else { + val scanResponse = getWalletsUseCase.invokeSync() + .firstOrNull { it.scanResponse.card.isVisa }?.scanResponse ?: return@launch + val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + } + } + } + + sendFeedbackEmailUseCase(feedbackType) } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 0c2faf9735..cf172f3a90 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -59,6 +59,8 @@ internal fun DetailsScreen( userWalletListBlockContent = userWalletListBlockContent, ) } + + SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig) } @Composable diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt new file mode 100644 index 0000000000..583b911d76 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt @@ -0,0 +1,58 @@ +package com.tangem.features.details.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.inputrow.InputRowChecked +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS +import com.tangem.features.details.impl.R + +@Composable +internal fun SelectFeedbackEmailTypeBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + titleText = resourceReference(R.string.common_choose_action), + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: SelectEmailFeedbackTypeBS) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + SelectEmailFeedbackTypeBS.Option.entries.forEachIndexed { index, type -> + DividerContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = SelectEmailFeedbackTypeBS.Option.entries.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClick(type) }, + showDivider = index != SelectEmailFeedbackTypeBS.Option.entries.lastIndex, + ) { + InputRowChecked( + text = type.text, + checked = false, + ) + } + } + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt index eef9deef0d..23cde30f76 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt @@ -18,19 +18,23 @@ import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.lce import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.core.utils.toLce +import com.tangem.domain.models.ArtworkModel import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.TotalFiatBalance import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.impl.R +import com.tangem.operations.attestation.ArtworkSize import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class UserWalletsFetcher @Inject constructor( getWalletsUseCase: GetWalletsUseCase, @@ -39,8 +43,11 @@ internal class UserWalletsFetcher @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val router: Router, private val messageSender: UiMessageSender, + private val getCardImageUseCase: GetCardImageUseCase, ) { + private var loadedArtworks: HashMap = hashMapOf() + @OptIn(ExperimentalCoroutinesApi::class) val userWallets: Flow> = getWalletsUseCase().transformLatest { wallets -> val uiModels = UserWalletItemUMConverter(onClick = ::navigateToWalletSettings).convertList(wallets) @@ -52,12 +59,14 @@ internal class UserWalletsFetcher @Inject constructor( flow = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getBalanceHidingSettingsUseCase().distinctUntilChanged(), flow3 = getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)).distinctUntilChanged(), - ) { maybeAppCurrency, balanceHidingSettings, maybeBalances -> + flow4 = loadArtworks(wallets), + ) { maybeAppCurrency, balanceHidingSettings, maybeBalances, artworks -> val models = createUiModels( wallets = wallets, maybeAppCurrency = maybeAppCurrency, maybeBalances = maybeBalances, balanceHidingSettings = balanceHidingSettings, + artworks = artworks, ).getOrElse( ifLoading = { return@combine }, ifError = { @@ -72,11 +81,27 @@ internal class UserWalletsFetcher @Inject constructor( }.collect() } + 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.forEach { wallet -> + val artwork = getCardImageUseCase( + cardId = wallet.cardId, + 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, ): Lce> = lce { val balances = withError( transform = { Error.UnableToGetBalances }, @@ -99,6 +124,7 @@ internal class UserWalletsFetcher @Inject constructor( appCurrency = appCurrency, balance = balance, isBalanceHidden = balanceHidingSettings.isBalanceHidden, + artwork = artworks[userWallet.walletId], ) .convert(userWallet) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt index 16c72a0b96..4dd21ecb8a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -27,7 +27,6 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider name = stringReference("Wallet 1"), information = stringReference("3 cards"), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, endIcon = UserWalletItemUM.EndIcon.Arrow, onClick = {}, diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTComponent.kt similarity index 66% rename from features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt rename to features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTComponent.kt index 3417b6a1f2..f49edd1b5b 100644 --- a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTComponent.kt @@ -4,11 +4,12 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.wallets.models.UserWalletId -interface NFTCollectionsComponent : ComposableContentComponent { +interface NFTComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, + val walletName: String, ) - interface Factory : ComponentFactory + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt similarity index 67% rename from features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt rename to features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt index 0f43e3a58f..676f626597 100644 --- a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt @@ -5,12 +5,13 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.wallets.models.UserWalletId -interface NFTDetailsComponent : ComposableContentComponent { +interface NFTDetailsBlockComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, val nftAsset: NFTAsset, + val nftCollectionName: String, ) - interface Factory : ComponentFactory + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTReceiveComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTReceiveComponent.kt deleted file mode 100644 index 9184afa340..0000000000 --- a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTReceiveComponent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.features.nft.component - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.wallets.models.UserWalletId - -interface NFTReceiveComponent : ComposableContentComponent { - - data class Params( - val userWalletId: UserWalletId, - ) - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index a33b3e1ad5..df09cb6d6a 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -27,6 +27,8 @@ dependencies { implementation(projects.core.datasource) /** Domain modules */ + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.models) implementation(projects.domain.nft) implementation(projects.domain.nft.models) implementation(projects.domain.tokens.models) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt similarity index 60% rename from features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt rename to features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt index bb82591e48..209883f972 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt @@ -6,17 +6,18 @@ 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.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.nft.collections.model.NFTCollectionsModel import com.tangem.features.nft.collections.ui.NFTCollections -import com.tangem.features.nft.component.NFTCollectionsComponent import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -internal class DefaultNFTCollectionsComponent @AssistedInject constructor( +internal class NFTCollectionsComponent @AssistedInject constructor( @Assisted context: AppComponentContext, - @Assisted private val params: NFTCollectionsComponent.Params, -) : NFTCollectionsComponent, AppComponentContext by context { + @Assisted private val params: Params, +) : ComposableContentComponent, AppComponentContext by context { private val model: NFTCollectionsModel = getOrCreateModel(params) @@ -27,11 +28,10 @@ internal class DefaultNFTCollectionsComponent @AssistedInject constructor( NFTCollections(state, modifier) } - @AssistedFactory - interface Factory : NFTCollectionsComponent.Factory { - override fun create( - context: AppComponentContext, - params: NFTCollectionsComponent.Params, - ): DefaultNFTCollectionsComponent - } + data class Params( + val userWalletId: UserWalletId, + val onBackClick: () -> Unit, + val onAssetClick: (asset: NFTAsset, collectionName: String) -> Unit, + val onReceiveClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt index e7e24ded3b..771f9515fc 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.extensions.TextReference internal data class NFTCollectionUM( val id: String, - val name: String, + val name: String?, @DrawableRes val networkIconId: Int, val logoUrl: String?, val description: TextReference, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt index 14267e3120..11a6d37285 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt @@ -1,6 +1,9 @@ package com.tangem.features.nft.collections.entity +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig + internal data class NFTCollectionsStateUM( val onBackClick: () -> Unit, + val pullToRefreshConfig: PullToRefreshConfig, val content: NFTCollectionsUM, ) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt index 394144b65d..a8287ca423 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt @@ -12,6 +12,7 @@ internal sealed class NFTCollectionsUM { ) : NFTCollectionsUM() data class Loading( + val search: SearchBarUM, val onReceiveClick: () -> Unit, ) : NFTCollectionsUM() diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeRefreshingStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeRefreshingStateTransformer.kt new file mode 100644 index 0000000000..7a2face48c --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeRefreshingStateTransformer.kt @@ -0,0 +1,15 @@ +package com.tangem.features.nft.collections.entity.transformer + +import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM +import com.tangem.utils.transformer.Transformer + +internal class ChangeRefreshingStateTransformer( + private val isRefreshing: Boolean, +) : Transformer { + + override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy( + pullToRefreshConfig = prevState.pullToRefreshConfig.copy( + isRefreshing = isRefreshing, + ), + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index e5fe0846c9..bb684cf18b 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.nft.models.* import com.tangem.features.nft.collections.entity.* @@ -20,12 +21,12 @@ internal class UpdateDataStateTransformer( private val onRetryClick: () -> Unit, private val onExpandCollectionClick: (NFTCollection) -> Unit, private val onRetryAssetsClick: (NFTCollection) -> Unit, - private val onAssetClick: (NFTAsset) -> Unit, + private val onAssetClick: (NFTAsset, String) -> Unit, private val initialSearchBarFactory: () -> SearchBarUM, ) : Transformer { - override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy( - content = when { + override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM { + val content = when { nftCollections.allCollectionsFailed() -> NFTCollectionsUM.Failed(onRetryClick, onReceiveClick) nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() -> @@ -33,7 +34,16 @@ internal class UpdateDataStateTransformer( nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> NFTCollectionsUM.Empty(onReceiveClick) !nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> - NFTCollectionsUM.Loading(onReceiveClick) + NFTCollectionsUM.Loading( + onReceiveClick = onReceiveClick, + search = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + isActive = false, + onQueryChange = { }, + onActiveChange = { }, + ), + ) else -> { NFTCollectionsUM.Content( search = if (prevState.content is NFTCollectionsUM.Content) { @@ -54,18 +64,45 @@ internal class UpdateDataStateTransformer( onReceiveClick = onReceiveClick, ) } - }, - ) + } + return prevState.copy( + content = content, + pullToRefreshConfig = when (content) { + is NFTCollectionsUM.Loading -> prevState.pullToRefreshConfig.copy( + isRefreshing = false, + ) + else -> prevState.pullToRefreshConfig + }, + ) + } private fun List.transform( state: NFTCollectionsStateUM, query: String, ): ImmutableList = mapNotNull { - if (query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true) { + val assetsFulfillQuery = if (query.isEmpty()) { + true + } else { + when (val assets = it.assets) { + is NFTCollection.Assets.Empty, + is NFTCollection.Assets.Failed, + is NFTCollection.Assets.Loading, + -> false + is NFTCollection.Assets.Value -> { + assets.items.any { asset -> + asset.name?.lowercase()?.contains(query.lowercase()) == true + } + } + } + } + + val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true + + if (collectionFulfillQuery || assetsFulfillQuery) { NFTCollectionUM( id = it.id.toString(), networkIconId = getActiveIconRes(it.network.id.value), - name = it.name.orEmpty(), + name = it.name, description = TextReference.PluralRes( R.plurals.nft_collections_count, it.count, @@ -105,12 +142,12 @@ internal class UpdateDataStateTransformer( is NFTCollection.Assets.Value -> NFTCollectionAssetsListUM.Content( items = assets .items - .map { it.transform() } + .map { it.transform(name.orEmpty()) } .toPersistentList(), ) } - private fun NFTAsset.transform(): NFTCollectionAssetUM = NFTCollectionAssetUM( + private fun NFTAsset.transform(collectionName: String): NFTCollectionAssetUM = NFTCollectionAssetUM( id = id.toString(), name = name.orEmpty(), imageUrl = media?.url, @@ -121,7 +158,7 @@ internal class UpdateDataStateTransformer( is NFTSalePrice.Value -> NFTSalePriceUM.Content(salePrice.value.toString()) }, onItemClick = { - onAssetClick(this) + onAssetClick(this, collectionName) }, ) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt index 21618350db..c35c9fc81f 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt @@ -1,24 +1,24 @@ package com.tangem.features.nft.collections.model -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.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.nft.FetchNFTCollectionAssetsUseCase import com.tangem.domain.nft.GetNFTCollectionsUseCase -import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.RefreshAllNFTUseCase import com.tangem.domain.nft.models.NFTCollection +import com.tangem.features.nft.collections.NFTCollectionsComponent import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.features.nft.collections.entity.transformer.* import com.tangem.features.nft.collections.entity.transformer.ChangeCollectionExpandedStateTransformer import com.tangem.features.nft.collections.entity.transformer.ToggleSearchBarTransformer import com.tangem.features.nft.collections.entity.transformer.UpdateDataStateTransformer import com.tangem.features.nft.collections.entity.transformer.UpdateSearchQueryTransformer -import com.tangem.features.nft.component.NFTCollectionsComponent import com.tangem.features.nft.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* @@ -29,23 +29,36 @@ import javax.inject.Inject @ModelScoped internal class NFTCollectionsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, private val searchManager: InputManager, private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase, private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase, + private val refreshAllNFTUseCase: RefreshAllNFTUseCase, paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = _state + private val params: NFTCollectionsComponent.Params = paramsContainer.require() private val _state = MutableStateFlow( value = NFTCollectionsStateUM( - onBackClick = ::navigateBack, - content = NFTCollectionsUM.Loading(::onReceiveClick), + onBackClick = params.onBackClick, + content = NFTCollectionsUM.Loading( + search = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + isActive = false, + onQueryChange = { }, + onActiveChange = { }, + ), + onReceiveClick = params.onReceiveClick, + ), + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = { onRefresh() }, + ), ), ) - private val params: NFTCollectionsComponent.Params = paramsContainer.require() + val state: StateFlow get() = _state init { subscribeToNFTCollections() @@ -60,18 +73,34 @@ internal class NFTCollectionsModel @Inject constructor( UpdateDataStateTransformer( nftCollections = nftCollections, searchQuery = query, - onReceiveClick = ::onReceiveClick, - onRetryClick = ::onRetryClick, + onReceiveClick = { + params.onReceiveClick() + }, + onRetryClick = ::onRefresh, onExpandCollectionClick = ::onExpandCollectionClick, onRetryAssetsClick = ::onRetryAssetsClick, - onAssetClick = ::onAssetClick, + onAssetClick = { asset, collectionName -> + params.onAssetClick(asset, collectionName) + }, initialSearchBarFactory = ::getInitialSearchBar, ).transform(it) } } + .onStart { onRefresh() } .launchIn(modelScope) } + private fun onRefresh() { + modelScope.launch { + _state.update { ChangeRefreshingStateTransformer(true).transform(it) } + try { + refreshAllNFTUseCase(params.userWalletId) + } finally { + _state.update { ChangeRefreshingStateTransformer(false).transform(it) } + } + } + } + private fun onSearchQueryChange(newQuery: String) { modelScope.launch { _state.update { UpdateSearchQueryTransformer(newQuery).transform(it) } @@ -111,31 +140,6 @@ internal class NFTCollectionsModel @Inject constructor( loadCollectionAssets(collection) } - private fun onRetryClick() { - // TODO refresh all - } - - private fun onAssetClick(asset: NFTAsset) { - router.push( - AppRoute.NFTDetails( - userWalletId = params.userWalletId, - nftAsset = asset, - ), - ) - } - - private fun onReceiveClick() { - router.push( - AppRoute.NFTReceive( - userWalletId = params.userWalletId, - ), - ) - } - - private fun navigateBack() { - router.pop() - } - private fun loadCollectionAssets(collection: NFTCollection) { modelScope.launch { fetchNFTCollectionAssetsUseCase( diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt index ffaf0c94b1..3851542d90 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt @@ -2,7 +2,6 @@ package com.tangem.features.nft.collections.ui import android.content.res.Configuration import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon @@ -11,25 +10,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource 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 coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge import com.tangem.core.ui.extensions.TextReference 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.res.TangemThemePreview import com.tangem.features.nft.collections.entity.NFTCollectionAssetsListUM import com.tangem.features.nft.collections.entity.NFTCollectionUM +import com.tangem.features.nft.common.ui.NFTLogo import com.tangem.features.nft.impl.R import kotlinx.collections.immutable.persistentListOf @@ -52,7 +46,11 @@ internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier ), verticalAlignment = Alignment.CenterVertically, ) { - Logo(state) + NFTLogo( + imageUrl = state.logoUrl, + networkIconId = state.networkIconId, + background = TangemTheme.colors.background.primary, + ) Text(state) @@ -81,46 +79,6 @@ internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier } } -@Composable -private fun Logo(state: NFTCollectionUM) { - val networkBadgeOffset = TangemTheme.dimens.spacing6 - - Box( - modifier = Modifier, - ) { - SubcomposeAsyncImage( - modifier = Modifier - .align(Alignment.CenterStart) - .size(TangemTheme.dimens.size36) - .clip(TangemTheme.shapes.roundedCorners8), - model = ImageRequest.Builder(LocalContext.current) - .data(state.logoUrl) - .crossfade(true) - .build(), - loading = { - RectangleShimmer(radius = TangemTheme.dimens.radius8) - }, - error = { - Box( - modifier = Modifier - .clip(shape = TangemTheme.shapes.roundedCorners8) - .background(TangemTheme.colors.field.primary), - ) - }, - contentScale = ContentScale.Crop, - contentDescription = null, - ) - CurrencyIconTopBadge( - modifier = Modifier - .offset(x = networkBadgeOffset, y = -networkBadgeOffset) - .align(Alignment.TopEnd), - iconResId = state.networkIconId, - alpha = 1f, - colorFilter = null, - ) - } -} - @Composable private fun RowScope.Text(state: NFTCollectionUM) { Column( @@ -132,7 +90,7 @@ private fun RowScope.Text(state: NFTCollectionUM) { ) { Text( modifier = Modifier, - text = state.name, + text = state.name.takeUnless { it.isNullOrEmpty() } ?: stringResourceSafe(R.string.nft_no_collection), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, maxLines = 1, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollections.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollections.kt index 9dd7e06ffc..2d42caaf85 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollections.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollections.kt @@ -1,12 +1,12 @@ package com.tangem.features.nft.collections.ui import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.* import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM @@ -29,19 +29,17 @@ internal fun NFTCollections(state: NFTCollectionsStateUM, modifier: Modifier = M ) }, content = { innerPadding -> - AnimatedContent( - targetState = state.content, - contentKey = { it::class }, - label = "NFT Collections", - ) { - val contentModifier = Modifier + TangemPullToRefreshContainer( + config = state.pullToRefreshConfig, + modifier = Modifier .padding(innerPadding) - .fillMaxSize() - when (val content = it) { - is NFTCollectionsUM.Content -> NFTCollectionsContent(content, contentModifier) - is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content, contentModifier) - is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content, contentModifier) - is NFTCollectionsUM.Loading -> Unit + .fillMaxSize(), + ) { + when (val content = state.content) { + is NFTCollectionsUM.Content -> NFTCollectionsContent(content) + is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content) + is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content) + is NFTCollectionsUM.Loading -> NFTCollectionsLoading(content) } } }, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt index e1301e1146..d774219ff8 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt @@ -1,19 +1,20 @@ package com.tangem.features.nft.collections.ui import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize +import androidx.compose.animation.* 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.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState +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.clip +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.PrimaryButton @@ -36,6 +37,8 @@ import kotlinx.collections.immutable.persistentListOf internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: Modifier = Modifier) { val listState = rememberLazyListState() + val bottomPadding = TangemTheme.dimens.spacing60 + Box( modifier = modifier .fillMaxSize() @@ -54,55 +57,72 @@ internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: state = content.search, colors = TangemSearchBarDefaults.secondaryTextFieldColors, ) - content.warnings.fastForEach { - key(it.id) { - NFTCollectionWarning( + if (content.collections.isEmpty()) { + Box( + modifier = modifier + .fillMaxSize() + .padding(bottom = bottomPadding), + ) { + Text( modifier = Modifier - .padding(top = TangemTheme.dimens.spacing16), - state = it, + .align(Alignment.Center), + text = stringResourceSafe(id = R.string.nft_empty_search), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, ) } - } - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding( - top = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing60, - ) - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.primary), - state = listState, - ) { - content.collections.fastForEach { collection -> - item(key = collection.id) { - NFTCollection( - modifier = Modifier.fillMaxWidth(), - state = collection, + } else { + content.warnings.fastForEach { + key(it.id) { + NFTCollectionWarning( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16), + state = it, ) } - when (val assets = collection.assets) { - is NFTCollectionAssetsListUM.Init -> Unit - is NFTCollectionAssetsListUM.Loading -> { - assetsListLoading( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing16, + bottom = bottomPadding, + ) + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary), + state = listState, + ) { + content.collections.fastForEach { collection -> + item(key = collection.id) { + NFTCollection( + modifier = Modifier.fillMaxWidth(), + state = collection, ) } - is NFTCollectionAssetsListUM.Failed -> { - assetsListFailed( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, - ) - } - is NFTCollectionAssetsListUM.Content -> { - assetsListContent( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, - ) + when (val assets = collection.assets) { + is NFTCollectionAssetsListUM.Init -> Unit + is NFTCollectionAssetsListUM.Loading -> { + assetsListLoading( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + ) + } + is NFTCollectionAssetsListUM.Failed -> { + assetsListFailed( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + ) + } + is NFTCollectionAssetsListUM.Content -> { + assetsListContent( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + ) + } } } } @@ -129,7 +149,11 @@ private fun LazyListScope.assetsListLoading( item( key = "loading_${collectionId}_$rowIndex", ) { - AnimatedVisibility(visible = expanded) { + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { val paddingValues = PaddingValues( start = TangemTheme.dimens.spacing6, top = TangemTheme.dimens.spacing6, @@ -139,8 +163,7 @@ private fun LazyListScope.assetsListLoading( Row( modifier = Modifier .fillMaxWidth() - .padding(TangemTheme.dimens.spacing6) - .animateContentSize(), + .padding(TangemTheme.dimens.spacing6), ) { NFTCollectionAssetLoading( modifier = Modifier @@ -174,12 +197,15 @@ private fun LazyListScope.assetsListFailed( item( key = "failed_$collectionId", ) { - AnimatedVisibility(visible = expanded) { + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { Box( modifier = Modifier .fillMaxWidth() - .height(TangemTheme.dimens.size142) - .animateContentSize(), + .height(TangemTheme.dimens.size142), contentAlignment = Alignment.Center, ) { UnableToLoadData( @@ -204,7 +230,11 @@ private fun LazyListScope.assetsListContent( item( key = "content_${collectionId}_${item1.id}_${item2?.id}", ) { - AnimatedVisibility(visible = expanded) { + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { val paddingValues = PaddingValues( start = TangemTheme.dimens.spacing6, top = TangemTheme.dimens.spacing6, @@ -214,8 +244,7 @@ private fun LazyListScope.assetsListContent( Row( modifier = Modifier .fillMaxWidth() - .padding(TangemTheme.dimens.spacing6) - .animateContentSize(), + .padding(TangemTheme.dimens.spacing6), ) { NFTCollectionAsset( modifier = Modifier diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsEmpty.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsEmpty.kt index 1376f56e72..1566f4d125 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsEmpty.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsEmpty.kt @@ -3,6 +3,8 @@ package com.tangem.features.nft.collections.ui import android.content.res.Configuration import androidx.compose.foundation.Image 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 @@ -23,6 +25,7 @@ internal fun NFTCollectionsEmpty(state: NFTCollectionsUM.Empty, modifier: Modifi Box( modifier = modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(TangemTheme.dimens.spacing16), ) { Column( diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt index d4d99bc548..2020fc02c6 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,6 +24,7 @@ internal fun NFTCollectionsFailed(state: NFTCollectionsUM.Failed, modifier: Modi Box( modifier = modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(TangemTheme.dimens.spacing16), ) { UnableToLoadData( diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsLoading.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsLoading.kt new file mode 100644 index 0000000000..0a50c87c68 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsLoading.kt @@ -0,0 +1,142 @@ +package com.tangem.features.nft.collections.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.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.tooling.preview.Preview +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.fields.SearchBar +import com.tangem.core.ui.components.fields.TangemSearchBarDefaults +import com.tangem.core.ui.components.fields.entity.SearchBarUM +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.res.TangemThemePreview +import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.features.nft.impl.R + +private const val SHIMMER_ITEMS_COUNT = 5 + +@Composable +internal fun NFTCollectionsLoading(state: NFTCollectionsUM.Loading, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(TangemTheme.dimens.spacing16), + ) { + Column { + SearchBar( + state = state.search, + colors = TangemSearchBarDefaults.secondaryTextFieldColors, + enabled = false, + ) + Card( + modifier = modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing16, + ), + shape = RoundedCornerShape(TangemTheme.dimens.radius16), + colors = CardDefaults.cardColors( + containerColor = TangemTheme.colors.background.primary, + contentColor = TangemTheme.colors.text.primary1, + disabledContainerColor = TangemTheme.colors.background.primary, + disabledContentColor = TangemTheme.colors.text.primary1, + ), + ) { + Column { + repeat(SHIMMER_ITEMS_COUNT) { + CollectionPlaceholder() + } + } + } + } + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + text = stringResourceSafe(R.string.nft_collections_receive), + onClick = state.onReceiveClick, + ) + } +} + +@Composable +private fun CollectionPlaceholder(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .padding( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing16, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .background(TangemTheme.colors.field.primary), + ) + } + Column( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing12, + ) + .weight(1f), + horizontalAlignment = Alignment.Start, + ) { + TextShimmer( + modifier = Modifier.width(TangemTheme.dimens.size110), + style = TangemTheme.typography.caption2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier + .width(TangemTheme.dimens.size80) + .padding(top = TangemTheme.dimens.spacing4), + style = TangemTheme.typography.caption2, + textSizeHeight = true, + ) + } + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTCollectionsLoading() { + TangemThemePreview { + NFTCollectionsLoading( + state = NFTCollectionsUM.Loading( + search = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + isActive = false, + onQueryChange = { }, + onActiveChange = { }, + ), + onReceiveClick = { }, + ), + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt new file mode 100644 index 0000000000..5b60f0c848 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -0,0 +1,176 @@ +package com.tangem.features.nft.common + +import androidx.activity.compose.BackHandler +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.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.value.ObserveLifecycleMode +import com.arkivanov.decompose.value.subscribe +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.nft.collections.NFTCollectionsComponent +import com.tangem.features.nft.common.ui.NFTContent +import com.tangem.features.nft.component.NFTComponent +import com.tangem.features.nft.details.NFTDetailsComponent +import com.tangem.features.nft.details.info.NFTDetailsInfoComponent +import com.tangem.features.nft.receive.NFTReceiveComponent +import com.tangem.features.nft.traits.NFTAssetTraitsComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +internal class DefaultNFTComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: NFTComponent.Params, + private val nftDetailsInfoComponentFactory: NFTDetailsInfoComponent.Factory, +) : NFTComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val initialRoute: NFTRoute = NFTRoute.Collections(params.userWalletId) + private val currentRoute = MutableStateFlow(initialRoute) + + private val childStack = childStack( + key = "sendInnerStack", + source = stackNavigation, + serializer = null, + initialConfiguration = initialRoute, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + configuration, + childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + init { + childStack.subscribe( + lifecycle = lifecycle, + mode = ObserveLifecycleMode.CREATE_DESTROY, + ) { stack -> + componentScope.launch { + currentRoute.emit(stack.active.configuration) + } + } + } + + @Composable + override fun Content(modifier: Modifier) { + val stackState by childStack.subscribeAsState() + + BackHandler(onBack = ::onChildBack) + NFTContent( + stackState = stackState, + ) + } + + private fun createChild(route: NFTRoute, factoryContext: AppComponentContext) = when (route) { + is NFTRoute.Collections -> getCollectionsComponent(factoryContext, route) + is NFTRoute.Receive -> getReceiveComponent(factoryContext, route) + is NFTRoute.Details -> getDetailsComponent(factoryContext, route) + is NFTRoute.AssetTraits -> getAssetTraitsComponent(factoryContext, route) + } + + private fun getCollectionsComponent( + factoryContext: AppComponentContext, + route: NFTRoute.Collections, + ): ComposableContentComponent = NFTCollectionsComponent( + context = factoryContext, + params = NFTCollectionsComponent.Params( + userWalletId = route.userWalletId, + onBackClick = ::onChildBack, + onReceiveClick = { + innerRouter.push( + NFTRoute.Receive( + userWalletId = route.userWalletId, + ), + ) + }, + onAssetClick = { asset, collectionName -> + innerRouter.push( + NFTRoute.Details( + userWalletId = route.userWalletId, + nftAsset = asset, + collectionName = collectionName, + ), + ) + }, + ), + ) + + private fun getReceiveComponent( + factoryContext: AppComponentContext, + route: NFTRoute.Receive, + ): ComposableContentComponent = NFTReceiveComponent( + context = factoryContext, + params = NFTReceiveComponent.Params( + userWalletId = route.userWalletId, + walletName = params.walletName, + onBackClick = ::onChildBack, + ), + ) + + private fun getDetailsComponent( + factoryContext: AppComponentContext, + route: NFTRoute.Details, + ): ComposableContentComponent = NFTDetailsComponent( + context = factoryContext, + params = NFTDetailsComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.collectionName, + onBackClick = ::onChildBack, + onAllTraitsClick = { + innerRouter.push( + NFTRoute.AssetTraits( + nftAsset = route.nftAsset, + ), + ) + }, + ), + nftDetailsInfoComponentFactory = nftDetailsInfoComponentFactory, + ) + + private fun getAssetTraitsComponent( + factoryContext: AppComponentContext, + route: NFTRoute.AssetTraits, + ): ComposableContentComponent = NFTAssetTraitsComponent( + context = factoryContext, + params = NFTAssetTraitsComponent.Params( + nftAsset = route.nftAsset, + onBackClick = ::onChildBack, + ), + ) + + private fun onChildBack() { + val isEmptyStack = childStack.value.backStack.isEmpty() + + if (isEmptyStack) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : NFTComponent.Factory { + override fun create(context: AppComponentContext, params: NFTComponent.Params): DefaultNFTComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt new file mode 100644 index 0000000000..5c3482f2f6 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt @@ -0,0 +1,31 @@ +package com.tangem.features.nft.common + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.serialization.Serializable + +internal sealed class NFTRoute : Route { + + @Serializable + data class Collections( + val userWalletId: UserWalletId, + ) : NFTRoute() + + @Serializable + data class Receive( + val userWalletId: UserWalletId, + ) : NFTRoute() + + @Serializable + data class Details( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val collectionName: String, + ) : NFTRoute() + + @Serializable + data class AssetTraits( + val nftAsset: NFTAsset, + ) : NFTRoute() +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt new file mode 100644 index 0000000000..ef907f1e0f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.nft.common.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.common.NFTRoute + +@Composable +internal fun NFTContent(stackState: ChildStack) { + Column( + modifier = Modifier.Companion + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.Companion.CenterHorizontally, + ) { + Children( + stack = stackState, + animation = stackAnimation(slide()), + modifier = Modifier.weight(1f), + ) { + it.instance.Content(Modifier.weight(1f)) + } + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt new file mode 100644 index 0000000000..db5f6d01ca --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt @@ -0,0 +1,60 @@ +package com.tangem.features.nft.common.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun NFTLogo(imageUrl: String?, @DrawableRes networkIconId: Int, background: Color = Color.Transparent) { + val networkBadgeOffset = TangemTheme.dimens.spacing6 + + Box( + modifier = Modifier, + ) { + SubcomposeAsyncImage( + modifier = Modifier + .align(Alignment.CenterStart) + .size(TangemTheme.dimens.size36) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + loading = { + RectangleShimmer(radius = TangemTheme.dimens.radius8) + }, + error = { + Box( + modifier = Modifier + .clip(shape = TangemTheme.shapes.roundedCorners8) + .background(TangemTheme.colors.field.primary), + ) + }, + contentScale = ContentScale.Crop, + contentDescription = null, + ) + CurrencyIconTopBadge( + modifier = Modifier + .offset(x = networkBadgeOffset, y = -networkBadgeOffset) + .align(Alignment.TopEnd), + iconResId = networkIconId, + alpha = 1f, + colorFilter = null, + background = background, + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt deleted file mode 100644 index bb220d9f9f..0000000000 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.features.nft.details - -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.nft.component.NFTDetailsComponent -import com.tangem.features.nft.details.model.NFTDetailsModel -import com.tangem.features.nft.details.ui.NFTDetails -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultNFTDetailsComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted private val params: NFTDetailsComponent.Params, -) : NFTDetailsComponent, AppComponentContext by context { - - private val model: NFTDetailsModel = getOrCreateModel(params) - - @Composable - override fun Content(modifier: Modifier) { - val state by model.state.collectAsStateWithLifecycle() - - NFTDetails(state, modifier) - } - - @AssistedFactory - interface Factory : NFTDetailsComponent.Factory { - override fun create( - context: AppComponentContext, - params: NFTDetailsComponent.Params, - ): DefaultNFTDetailsComponent - } -} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/NFTDetailsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/NFTDetailsComponent.kt new file mode 100644 index 0000000000..2333a6a0e0 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/NFTDetailsComponent.kt @@ -0,0 +1,71 @@ +package com.tangem.features.nft.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.nft.details.entity.NFTDetailsBottomSheetConfig +import com.tangem.features.nft.details.info.NFTDetailsInfoComponent +import com.tangem.features.nft.details.model.NFTDetailsModel +import com.tangem.features.nft.details.ui.NFTDetails +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +internal class NFTDetailsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, + private val nftDetailsInfoComponentFactory: NFTDetailsInfoComponent.Factory, +) : ComposableContentComponent, AppComponentContext by context { + + private val model: NFTDetailsModel = 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.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + NFTDetails(state, modifier) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: NFTDetailsBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + is NFTDetailsBottomSheetConfig.Info -> nftDetailsInfoComponentFactory.create( + context = childByContext(componentContext), + params = NFTDetailsInfoComponent.Params( + text = config.text, + onDismiss = { + model.bottomSheetNavigation.dismiss() + }, + ), + ) + } + + data class Params( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val nftCollectionName: String, + val onBackClick: () -> Unit, + val onAllTraitsClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt new file mode 100644 index 0000000000..1c20de2872 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.nft.details.block + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.nft.details.block.ui.NFTDetailsBlock +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +class DefaultNFTDetailsBlockComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTDetailsBlockComponent.Params, +) : NFTDetailsBlockComponent, AppComponentContext by context { + + @Composable + override fun Content(modifier: Modifier) { + NFTDetailsBlock( + assetName = stringReference(params.nftAsset.name.orEmpty()), + collectionName = stringReference(params.nftCollectionName), + assetImage = params.nftAsset.media?.url, + networkIconRes = getActiveIconRes(params.nftAsset.network.id.value), + ) + } + + @AssistedFactory + interface Factory : NFTDetailsBlockComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTDetailsBlockComponent.Params, + ): DefaultNFTDetailsBlockComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt new file mode 100644 index 0000000000..6336d2dbb2 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt @@ -0,0 +1,84 @@ +package com.tangem.features.nft.details.block.ui + +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.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.tooling.preview.Preview +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.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.common.ui.NFTLogo +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTDetailsBlock( + assetName: TextReference, + collectionName: TextReference, + assetImage: String?, + networkIconRes: Int, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "NFT Asset", + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NFTLogo( + assetImage, + networkIconRes, + background = TangemTheme.colors.background.action, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = assetName.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = collectionName.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun NFTDetailsBlock_Preview() { + TangemThemePreview { + NFTDetailsBlock( + assetName = stringReference("NFT Asset Name"), + collectionName = stringReference("NFT Collection"), + assetImage = null, + networkIconRes = R.drawable.img_polygon_22, + ) + } +} +// endregion \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt index 2b85b58fe8..22243c075c 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt @@ -3,7 +3,9 @@ package com.tangem.features.nft.details.entity import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal data class NFTAssetUM( val name: String, @@ -37,7 +39,13 @@ data class NFTAssetUM( sealed class SalePrice { data object Loading : SalePrice() data object Empty : SalePrice() - data class Content(val value: String, val fiatValue: String) : SalePrice() + data class Content( + val value: BigDecimal, + val symbol: String, + val decimals: Int, + val rate: BigDecimal?, + val appCurrency: AppCurrency, + ) : SalePrice() } @Immutable diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsBottomSheetConfig.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsBottomSheetConfig.kt new file mode 100644 index 0000000000..e8478f38ac --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsBottomSheetConfig.kt @@ -0,0 +1,12 @@ +package com.tangem.features.nft.details.entity + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface NFTDetailsBottomSheetConfig { + @Serializable + data class Info( + val text: TextReference, + ) : NFTDetailsBottomSheetConfig +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt index 55df42f2df..95844969ae 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt @@ -1,13 +1,10 @@ package com.tangem.features.nft.details.entity -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig - internal data class NFTDetailsUM( val nftAsset: NFTAssetUM, val onBackClick: () -> Unit, val onReadMoreClick: () -> Unit, - val onSeeAllClick: () -> Unit, + val onSeeAllTraitsClick: () -> Unit, val onExploreClick: () -> Unit, val onSendClick: () -> Unit, - val bottomSheetConfig: TangemBottomSheetConfig?, ) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/factory/NFTDetailsUMFactory.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/factory/NFTDetailsUMFactory.kt new file mode 100644 index 0000000000..f23446ab57 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/factory/NFTDetailsUMFactory.kt @@ -0,0 +1,126 @@ +package com.tangem.features.nft.details.entity.factory + +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTSalePrice +import com.tangem.features.nft.details.entity.NFTAssetUM +import com.tangem.features.nft.details.entity.NFTDetailsUM +import com.tangem.features.nft.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class NFTDetailsUMFactory( + private val onBackClick: () -> Unit, + private val onReadMoreClick: () -> Unit, + private val onSeeAllTraitsClick: () -> Unit, + private val onExploreClick: () -> Unit, + private val onSendClick: () -> Unit, +) { + + fun getInitialState(nftAsset: NFTAsset): NFTDetailsUM = NFTDetailsUM( + nftAsset = nftAsset.transform(), + onBackClick = onBackClick, + onReadMoreClick = onReadMoreClick, + onSeeAllTraitsClick = onSeeAllTraitsClick, + onExploreClick = onExploreClick, + onSendClick = onSendClick, + ) + + private fun NFTAsset.transform(): NFTAssetUM = NFTAssetUM( + name = name.orEmpty(), + media = media?.let { + NFTAssetUM.Media.Content( + mimetype = it.mimetype, + url = it.url, + ) + } ?: NFTAssetUM.Media.Empty, + topInfo = when { + !hasSalePrice() && description.isNullOrEmpty() && rarity == null -> { + NFTAssetUM.TopInfo.Empty + } + else -> { + val hasSalePrice = hasSalePrice() + val hasDescription = !description.isNullOrEmpty() + val rarity = rarity + NFTAssetUM.TopInfo.Content( + title = if (hasSalePrice) { + resourceReference(R.string.nft_details_last_sale_price) + } else { + null + }, + salePrice = NFTAssetUM.SalePrice.Empty, + description = description, + rarity = if (rarity != null) { + NFTAssetUM.Rarity.Content( + rank = rarity.rank, + label = rarity.label, + showDivider = hasSalePrice || hasDescription, + ) + } else { + NFTAssetUM.Rarity.Empty + }, + ) + } + }, + traits = traits.take(MAX_TRAITS_COUNT).map { + NFTAssetUM.BlockItem( + title = stringReference(it.name), + value = it.value, + ) + }.toImmutableList(), + baseInfoItems = buildBaseInfoItems(), + ) + + private fun NFTAsset.hasSalePrice() = salePrice !is NFTSalePrice.Empty && salePrice !is NFTSalePrice.Error + + private fun NFTAsset.buildBaseInfoItems() = when (val id = id) { + is NFTAsset.Identifier.EVM -> persistentListOf( + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_standard), + value = contractType, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_contract_address), + value = id.tokenAddress, + valueTextEllipsis = TextEllipsis.Middle, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_id), + value = id.tokenId, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_chain), + value = network.name, + ), + ) + is NFTAsset.Identifier.TON -> persistentListOf( + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_address), + value = id.tokenAddress, + valueTextEllipsis = TextEllipsis.Middle, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_chain), + value = network.name, + ), + ) + is NFTAsset.Identifier.Solana -> persistentListOf( + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_address), + value = id.tokenAddress, + valueTextEllipsis = TextEllipsis.Middle, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_chain), + value = network.name, + ), + ) + is NFTAsset.Identifier.Unknown -> persistentListOf() + } + + companion object { + private const val MAX_TRAITS_COUNT = 6 + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/DefaultNFTDetailsInfoComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/DefaultNFTDetailsInfoComponent.kt new file mode 100644 index 0000000000..26dc1a1dd6 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/DefaultNFTDetailsInfoComponent.kt @@ -0,0 +1,54 @@ +package com.tangem.features.nft.details.info + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +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.res.TangemTheme +import com.tangem.features.nft.details.info.ui.NFTInfoBottomSheet +import com.tangem.features.nft.details.info.ui.NFTInfoBottomSheetContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNFTDetailsInfoComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTDetailsInfoComponent.Params, +) : NFTDetailsInfoComponent, AppComponentContext by context { + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val bottomSheetConfig = remember(key1 = this) { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ) + } + NFTInfoBottomSheet( + config = bottomSheetConfig, + content = { + NFTInfoBottomSheetContent( + text = params.text, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + }, + ) + } + + @AssistedFactory + interface Factory : NFTDetailsInfoComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTDetailsInfoComponent.Params, + ): DefaultNFTDetailsInfoComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/NFTDetailsInfoComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/NFTDetailsInfoComponent.kt new file mode 100644 index 0000000000..90650da63b --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/NFTDetailsInfoComponent.kt @@ -0,0 +1,15 @@ +package com.tangem.features.nft.details.info + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.TextReference + +internal interface NFTDetailsInfoComponent : ComposableBottomSheetComponent { + + data class Params( + val text: TextReference, + val onDismiss: () -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/ui/NFTInfoBottomSheet.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/ui/NFTInfoBottomSheet.kt new file mode 100644 index 0000000000..b95179836f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/ui/NFTInfoBottomSheet.kt @@ -0,0 +1,69 @@ +package com.tangem.features.nft.details.info.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +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.tooling.preview.Preview +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.extensions.TextReference +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.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTInfoBottomSheet(config: TangemBottomSheetConfig, content: @Composable () -> Unit) { + TangemBottomSheet( + config = config, + titleText = resourceReference(R.string.nft_about_title), + content = { content() }, + ) +} + +@Composable +internal fun NFTInfoBottomSheetContent(text: TextReference, modifier: Modifier = Modifier) { + val scrollState = rememberScrollState() + Column( + modifier = modifier.verticalScroll(scrollState), + ) { + Text( + text = text.resolveReference(), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2, + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) + } +} + +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_NFTInfoBottomSheetContent() { + TangemThemePreview { + NFTInfoBottomSheetContent( + text = stringReference( + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce varius neque vel ligula + tincidunt, nec faucibus nulla ultricies. Maecenas euismod arcu in nunc volutpat, + at bibendum eros lacinia. Proin hendrerit massa non velit congue, + in volutpat nisi consequat. Sed vitae justo nec orci tincidunt malesuada. + Nullam feugiat purus vel lectus efficitur, vel fringilla urna volutpat. + Donec sagittis enim in metus lacinia, vel tempor nunc bibendum. + """.trimIndent(), + ), + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt index e421efcde8..e81bb43891 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt @@ -1,64 +1,94 @@ package com.tangem.features.nft.details.model +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler 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.features.nft.component.NFTDetailsComponent +import com.tangem.domain.nft.analytics.NFTAnalyticsEvent +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.nft.GetNFTExploreUrlUseCase +import com.tangem.features.nft.details.NFTDetailsComponent import com.tangem.features.nft.details.entity.NFTAssetUM +import com.tangem.features.nft.details.entity.NFTDetailsBottomSheetConfig import com.tangem.features.nft.details.entity.NFTDetailsUM +import com.tangem.features.nft.details.entity.factory.NFTDetailsUMFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped internal class NFTDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, + private val analyticsEventHandler: AnalyticsEventHandler, + private val urlOpener: UrlOpener, + private val getNFTExploreUrlUseCase: GetNFTExploreUrlUseCase, paramsContainer: ParamsContainer, ) : Model() { - @Suppress("UnusedPrivateMember") private val params: NFTDetailsComponent.Params = paramsContainer.require() val state: StateFlow get() = _state - private val _state = MutableStateFlow( - value = NFTDetailsUM( - nftAsset = NFTAssetUM( - name = "", - media = NFTAssetUM.Media.Empty, - topInfo = NFTAssetUM.TopInfo.Empty, - traits = persistentListOf(), - baseInfoItems = persistentListOf(), - ), - onBackClick = ::navigateBack, - onReadMoreClick = ::onReadMoreClick, - onSeeAllClick = ::onSeeAllClick, - onExploreClick = ::onExploreClick, - onSendClick = ::onSendClick, - bottomSheetConfig = null, - ), + private val stateFactory: NFTDetailsUMFactory = NFTDetailsUMFactory( + onBackClick = { params.onBackClick() }, + onReadMoreClick = ::onReadMoreClick, + onSeeAllTraitsClick = { params.onAllTraitsClick() }, + onExploreClick = ::onExploreClick, + onSendClick = ::onSendClick, ) - private fun onReadMoreClick() { - // TODO implement + private val _state = MutableStateFlow( + value = stateFactory.getInitialState(params.nftAsset), + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + init { + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ScreenOpened(params.nftAsset.network.name)) } - private fun onSeeAllClick() { - // TODO implement + private fun onReadMoreClick() { + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ButtonReadMore) + when (val topInfo = _state.value.nftAsset.topInfo) { + is NFTAssetUM.TopInfo.Empty -> Unit + is NFTAssetUM.TopInfo.Content -> { + bottomSheetNavigation.activate( + NFTDetailsBottomSheetConfig.Info( + text = stringReference(topInfo.description.orEmpty()), + ), + ) + } + } } private fun onExploreClick() { - // TODO implement + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ButtonExplore) + modelScope.launch { + val url = getNFTExploreUrlUseCase.invoke( + network = params.nftAsset.network, + assetIdentifier = params.nftAsset.id, + ) + if (url != null) { + urlOpener.openUrl(url) + } + } } private fun onSendClick() { - // TODO implement - } - - private fun navigateBack() { - router.pop() + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ButtonSend) + router.push( + AppRoute.NFTSend( + userWalletId = params.userWalletId, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + ), + ) } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt index 31af37f744..b662151b8d 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt @@ -37,7 +37,7 @@ internal fun NFTDetails(state: NFTDetailsUM, modifier: Modifier = Modifier) { NFTDetailsAsset( state = state.nftAsset, onReadMoreClick = state.onReadMoreClick, - onSeeAllClick = state.onSeeAllClick, + onSeeAllTraitsClick = state.onSeeAllTraitsClick, onExploreClick = state.onExploreClick, modifier = Modifier .padding(innerPadding), diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt index b3806888f4..233c56d281 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt @@ -16,15 +16,17 @@ 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.domain.appcurrency.model.AppCurrency import com.tangem.features.nft.details.entity.NFTAssetUM import com.tangem.features.nft.impl.R import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal @Composable internal fun NFTDetailsAsset( state: NFTAssetUM, onReadMoreClick: () -> Unit, - onSeeAllClick: () -> Unit, + onSeeAllTraitsClick: () -> Unit, onExploreClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -72,7 +74,7 @@ internal fun NFTDetailsAsset( NFTBlocksGroupAction( text = resourceReference(R.string.common_see_all), startIcon = { }, - onClick = onSeeAllClick, + onClick = onSeeAllTraitsClick, ) }, ) @@ -103,7 +105,7 @@ private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetProvider::cla NFTDetailsAsset( state = state, onReadMoreClick = { }, - onSeeAllClick = { }, + onSeeAllTraitsClick = { }, onExploreClick = { }, ) } @@ -120,7 +122,13 @@ private class NFTAssetProvider : CollectionPreviewParameterProvider( ), topInfo = NFTAssetUM.TopInfo.Content( title = resourceReference(R.string.nft_details_last_sale_price), - salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"), + salePrice = NFTAssetUM.SalePrice.Content( + value = BigDecimal("18.75"), + symbol = "ETH", + decimals = 18, + rate = BigDecimal("1"), + appCurrency = AppCurrency.Default, + ), description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), rarity = NFTAssetUM.Rarity.Content( rank = "Top 1% rarity", diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt index aa34278977..8f71b0c87c 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt @@ -3,24 +3,34 @@ package com.tangem.features.nft.details.ui import android.content.res.Configuration import androidx.compose.animation.* import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.common.ui.amountScreen.utils.getFiatString import com.tangem.core.ui.components.TextShimmer -import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.ReadMoreText import com.tangem.core.ui.components.block.information.InformationBlock +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.format.bigdecimal.crypto +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.domain.appcurrency.model.AppCurrency import com.tangem.features.nft.details.entity.NFTAssetUM import com.tangem.features.nft.impl.R +import java.math.BigDecimal + +private const val READ_MORE_MAX_LINES = 3 @Composable internal fun NFTDetailsInfoGroup( @@ -41,7 +51,10 @@ internal fun NFTDetailsInfoGroup( contentHorizontalPadding = 0.dp, ) { Column( - modifier = Modifier, + modifier = Modifier + .padding( + bottom = TangemTheme.dimens.spacing12, + ), ) { SalePrice(state.salePrice) Description(state.description, onReadMoreClick) @@ -68,7 +81,6 @@ private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier .padding( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing12, ), ) { TextShimmer( @@ -92,19 +104,23 @@ private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier .padding( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing12, ), verticalArrangement = Arrangement.SpaceAround, ) { Text( - text = state.value, + text = state.value.format { + crypto( + symbol = state.symbol, + decimals = state.decimals, + ) + }, style = TangemTheme.typography.head, color = TangemTheme.colors.text.primary1, ) Text( modifier = Modifier .padding(top = TangemTheme.dimens.spacing4), - text = state.fiatValue, + text = getFiatString(state.value, state.rate, state.appCurrency), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) @@ -117,18 +133,29 @@ private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier @Composable private fun Description(description: String?, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier) { if (!description.isNullOrEmpty()) { - // TODO configure right way to display 3 rows and "Read more" label - EllipsisText( + val interactionSource = remember { MutableInteractionSource() } + ReadMoreText( modifier = modifier - .clickable { onReadMoreClick() } + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onReadMoreClick, + ) .padding( - horizontal = TangemTheme.dimens.spacing12, - vertical = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, ), + expanded = false, text = description, style = TangemTheme.typography.body2.copy( color = TangemTheme.colors.text.tertiary, ), + readMoreText = resourceReference(R.string.common_read_more).resolveReference(), + readMoreMaxLines = READ_MORE_MAX_LINES, + readMoreStyle = TangemTheme.typography.body2.copy( + color = TangemTheme.colors.text.accent, + ).toSpanStyle(), ) } } @@ -141,7 +168,9 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { if (state.showDivider) { HorizontalDivider( modifier = Modifier.padding( - horizontal = TangemTheme.dimens.spacing12, + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing8, + end = TangemTheme.dimens.spacing12, ), color = TangemTheme.colors.stroke.primary, ) @@ -150,8 +179,9 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { modifier = modifier .fillMaxWidth() .padding( - horizontal = TangemTheme.dimens.spacing6, - vertical = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing6, + top = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing6, ), ) { NFTDetailsGroupBlock( @@ -159,7 +189,6 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { .weight(1f) .padding( horizontal = TangemTheme.dimens.spacing6, - vertical = TangemTheme.dimens.spacing4, ), title = resourceReference(R.string.nft_details_rarity_label), value = stringReference(state.label), @@ -169,7 +198,6 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { .weight(1f) .padding( horizontal = TangemTheme.dimens.spacing6, - vertical = TangemTheme.dimens.spacing4, ), title = resourceReference(R.string.nft_details_rarity_rank), value = stringReference(state.rank), @@ -196,7 +224,13 @@ private class NFTAssetInfoProvider : CollectionPreviewParameterProvider Unit, + ) } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTNetworkUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTNetworkUM.kt index 0ef73aa689..dc3c6ae968 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTNetworkUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTNetworkUM.kt @@ -1,10 +1,9 @@ package com.tangem.features.nft.receive.entity -import androidx.annotation.DrawableRes +import com.tangem.core.ui.components.rows.model.ChainRowUM internal data class NFTNetworkUM( val id: String, - @DrawableRes val iconRes: Int, - val name: String, + val chainRowUM: ChainRowUM, val onItemClick: () -> Unit, ) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTReceiveUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTReceiveUM.kt index 0764b2f074..6bb4b1f9f2 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTReceiveUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTReceiveUM.kt @@ -3,10 +3,12 @@ package com.tangem.features.nft.receive.entity import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList internal data class NFTReceiveUM( val onBackClick: () -> Unit, + val appBarSubtitle: TextReference, val search: SearchBarUM, val networks: Networks, val bottomSheetConfig: TangemBottomSheetConfig?, @@ -17,7 +19,8 @@ internal data class NFTReceiveUM( data object Empty : Networks() data class Content( - val items: ImmutableList, + val availableItems: ImmutableList, + val unavailableItems: ImmutableList, ) : Networks() } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt index 560bf018f7..d9f6d6a4b0 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt @@ -1,6 +1,9 @@ package com.tangem.features.nft.receive.entity.transformer +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.rows.model.ChainRowUM import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.domain.nft.models.NFTNetworks import com.tangem.domain.tokens.model.Network import com.tangem.features.nft.receive.entity.NFTNetworkUM import com.tangem.features.nft.receive.entity.NFTReceiveUM @@ -8,27 +11,44 @@ import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.toPersistentList internal class UpdateDataStateTransformer( - private val networks: List, - private val onNetworkClick: (Network) -> Unit, + private val networks: NFTNetworks, + private val onNetworkClick: (Network, Boolean) -> Unit, ) : Transformer { override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy( networks = when { - networks.isEmpty() -> NFTReceiveUM.Networks.Empty + networks.availableNetworks.isEmpty() && networks.unavailableNetworks.isEmpty() + -> NFTReceiveUM.Networks.Empty else -> NFTReceiveUM.Networks.Content( - items = networks - .map { it.transform() } + availableItems = networks.availableNetworks + .map { it.transform(true) } + .toPersistentList(), + unavailableItems = networks.unavailableNetworks + .map { it.transform(false) } .toPersistentList(), ) }, ) - private fun Network.transform(): NFTNetworkUM = NFTNetworkUM( - id = id.value, - iconRes = getActiveIconRes(id.value), - name = name, - onItemClick = { - onNetworkClick(this) - }, - ) + private fun Network.transform(enabled: Boolean): NFTNetworkUM { + val custom = derivationPath is Network.DerivationPath.Custom + return NFTNetworkUM( + id = id.value + derivationPath.value, + chainRowUM = ChainRowUM( + name = name, + type = "", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = getActiveIconRes(id.value), + isGrayscale = !enabled, + showCustomBadge = custom, + ), + showCustom = custom, + enabled = enabled, + ), + onItemClick = { + onNetworkClick(this, enabled) + }, + ) + } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt index dc0eefd9b3..a093929cf3 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt @@ -1,21 +1,25 @@ package com.tangem.features.nft.receive.model +import com.tangem.core.analytics.api.AnalyticsEventHandler 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.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.nft.FilterNFTAvailableNetworksUseCase -import com.tangem.domain.nft.GetNFTAvailableNetworksUseCase +import com.tangem.domain.nft.GetNFTNetworksUseCase import com.tangem.domain.nft.GetNFTNetworkStatusUseCase +import com.tangem.domain.nft.analytics.NFTAnalyticsEvent import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.NetworkStatus -import com.tangem.features.nft.component.NFTReceiveComponent import com.tangem.features.nft.impl.R +import com.tangem.features.nft.receive.NFTReceiveComponent import com.tangem.features.nft.receive.entity.NFTReceiveUM import com.tangem.features.nft.receive.entity.transformer.ShowReceiveBottomSheetTransformer import com.tangem.features.nft.receive.entity.transformer.ToggleSearchBarTransformer @@ -31,36 +35,45 @@ import javax.inject.Inject @ModelScoped internal class NFTReceiveModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, private val searchManager: InputManager, - private val getNFTAvailableNetworksUseCase: GetNFTAvailableNetworksUseCase, + private val getNFTNetworksUseCase: GetNFTNetworksUseCase, private val filterNFTAvailableNetworksUseCase: FilterNFTAvailableNetworksUseCase, private val getNFTNetworkStatusUseCase: GetNFTNetworkStatusUseCase, private val clipboardManager: ClipboardManager, private val shareManager: ShareManager, + private val analyticsEventHandler: AnalyticsEventHandler, + private val messageSender: UiMessageSender, paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = _state + private val params: NFTReceiveComponent.Params = paramsContainer.require() private val _state = MutableStateFlow( value = NFTReceiveUM( - onBackClick = ::navigateBack, + onBackClick = params.onBackClick, + appBarSubtitle = resourceReference( + R.string.hot_crypto_add_token_subtitle, + formatArgs = wrappedList(params.walletName), + ), search = getInitialSearchBar(), - networks = NFTReceiveUM.Networks.Content(persistentListOf()), + networks = NFTReceiveUM.Networks.Content( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + ), bottomSheetConfig = null, ), ) - private val params: NFTReceiveComponent.Params = paramsContainer.require() + val state: StateFlow get() = _state init { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.ScreenOpened) subscribeToNFTAvailableNetworks() } private fun subscribeToNFTAvailableNetworks() { combine( - flow = getNFTAvailableNetworksUseCase(params.userWalletId), + flow = getNFTNetworksUseCase(params.userWalletId), flow2 = searchManager.query.distinctUntilChanged(), ) { networks, query -> filterNFTAvailableNetworksUseCase(networks, query) @@ -106,42 +119,51 @@ internal class NFTReceiveModel @Inject constructor( } } - private fun onNetworkClick(network: Network) { - modelScope.launch { - val networkStatus = getNFTNetworkStatusUseCase.invoke( - userWalletId = params.userWalletId, - network = network, - ) ?: return@launch + private fun onNetworkClick(network: Network, enabled: Boolean) { + if (!enabled) { + val message = DialogMessage( + title = resourceReference(R.string.nft_receive_unavailable_asset_warning_title), + message = resourceReference(R.string.nft_receive_unavailable_asset_warning_message), + ) - when (val value = networkStatus.value) { - is NetworkStatus.Verified -> { - _state.update { - ShowReceiveBottomSheetTransformer( - network = network, - networkAddress = value.address, - onDismissBottomSheet = ::onReceiveBottomSheetDismiss, - onCopyClick = ::onCopyClick, - onShareClick = ::onShareClick, - ).transform(it) + messageSender.send(message) + } else { + modelScope.launch { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.BlockchainChosen(network.name)) + + val networkStatus = getNFTNetworkStatusUseCase.invoke( + userWalletId = params.userWalletId, + network = network, + ) ?: return@launch + + when (val value = networkStatus.value) { + is NetworkStatus.Verified -> { + _state.update { + ShowReceiveBottomSheetTransformer( + network = network, + networkAddress = value.address, + onDismissBottomSheet = ::onReceiveBottomSheetDismiss, + onCopyClick = { text -> onCopyClick(text, network) }, + onShareClick = { text -> onShareClick(text, network) }, + ).transform(it) + } } + is NetworkStatus.MissedDerivation, + is NetworkStatus.NoAccount, + is NetworkStatus.Unreachable, + -> Unit } - is NetworkStatus.MissedDerivation, - is NetworkStatus.NoAccount, - is NetworkStatus.Unreachable, - -> Unit } } } - private fun onCopyClick(text: String) { + private fun onCopyClick(text: String, network: Network) { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.CopyAddress(network.name)) clipboardManager.setText(text = text, isSensitive = true) } - private fun onShareClick(text: String) { + private fun onShareClick(text: String, network: Network) { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.ShareAddress(network.name)) shareManager.shareText(text = text) } - - private fun navigateBack() { - router.pop() - } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTNetwork.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTNetwork.kt deleted file mode 100644 index 9b60320b49..0000000000 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTNetwork.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.features.nft.receive.ui - -import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -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.res.painterResource -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.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.nft.impl.R -import com.tangem.features.nft.receive.entity.NFTNetworkUM - -@Composable -internal fun NFTNetwork(state: NFTNetworkUM, modifier: Modifier = Modifier) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable { state.onItemClick() } - .padding(TangemTheme.dimens.spacing12), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - modifier = Modifier - .size(TangemTheme.dimens.size24) - .clip(CircleShape), - painter = painterResource(state.iconRes), - contentDescription = null, - ) - Text( - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing12), - text = state.name, - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Preview(widthDp = 360, showBackground = true) -@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_NFTNetwork(@PreviewParameter(NFTNetworkProvider::class) state: NFTNetworkUM) { - TangemThemePreview { - NFTNetwork( - state = state, - ) - } -} - -private class NFTNetworkProvider : CollectionPreviewParameterProvider( - collection = listOf( - NFTNetworkUM( - id = "item1", - name = "Nethers #0853", - iconRes = R.drawable.img_eth_22, - onItemClick = { }, - ), - ), -) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceive.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceive.kt index 69acaeef22..4dac3a8d5c 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceive.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceive.kt @@ -1,7 +1,6 @@ package com.tangem.features.nft.receive.ui import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.* import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -13,6 +12,7 @@ import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.nft.impl.R @@ -33,7 +33,7 @@ internal fun NFTReceive(state: NFTReceiveUM, modifier: Modifier = Modifier) { onIconClicked = state.onBackClick, ), title = stringResourceSafe(id = R.string.nft_receive_title), - subtitle = stringResourceSafe(id = R.string.nft_receive_subtitle), + subtitle = state.appBarSubtitle.resolveReference(), ) }, content = { innerPadding -> @@ -50,18 +50,9 @@ internal fun NFTReceive(state: NFTReceiveUM, modifier: Modifier = Modifier) { colors = TangemSearchBarDefaults.secondaryTextFieldColors, ) - val contentModifier = Modifier - .padding(TangemTheme.dimens.spacing16) - - AnimatedContent( - targetState = state.networks, - contentKey = { it::class }, - label = "NFT Receive", - ) { - when (val networks = it) { - is NFTReceiveUM.Networks.Content -> NFTReceiveNetworksContent(networks, contentModifier) - is NFTReceiveUM.Networks.Empty -> NFTReceiveNetworksEmpty(contentModifier) - } + when (val networks = state.networks) { + is NFTReceiveUM.Networks.Content -> NFTReceiveNetworksContent(networks) + is NFTReceiveUM.Networks.Empty -> NFTReceiveNetworksEmpty() } } }, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksContent.kt index 23fab1ef15..26d245c7f2 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksContent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksContent.kt @@ -1,19 +1,29 @@ package com.tangem.features.nft.receive.ui import android.content.res.Configuration -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed 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.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.rows.ChainRow +import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.extensions.TextReference +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.features.nft.impl.R @@ -27,40 +37,86 @@ internal fun NFTReceiveNetworksContent(state: NFTReceiveUM.Networks.Content, mod LazyColumn( modifier = modifier - .fillMaxWidth() - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.primary) - .animateContentSize(), + .fillMaxSize() + .padding( + vertical = TangemTheme.dimens.spacing8, + ), state = listState, + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing16, + ), ) { - item( - key = "title", - ) { - Title() + if (state.availableItems.isNotEmpty()) { + item( + key = "available_title", + ) { + Title(resourceReference(R.string.nft_receive_available_section_title)) + } + networks(state.availableItems) } - items( - items = state.items, - key = NFTNetworkUM::id, - ) { network -> - NFTNetwork( - state = network, - modifier = Modifier.animateItem(), - ) + if (state.unavailableItems.isNotEmpty()) { + item( + key = "not_available_title", + ) { + Title(resourceReference(R.string.nft_receive_unavailable_section_title)) + } + networks(state.unavailableItems) } } } +private fun LazyListScope.networks(items: List) { + itemsIndexed( + items = items, + key = { _, item -> item.id }, + ) { index, network -> + val isLastItem = index == items.size - 1 + ChainRow( + modifier = Modifier + .composed { + if (isLastItem) { + padding( + bottom = TangemTheme.dimens.spacing8, + ) + .clip( + RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ), + ) + } else { + this + } + } + .background(TangemTheme.colors.background.primary) + .clickable { network.onItemClick() }, + model = network.chainRowUM, + ) + } +} + @Composable -private fun Title(modifier: Modifier = Modifier) { +private fun Title(text: TextReference, modifier: Modifier = Modifier) { Text( modifier = modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing8, + ) + .background( + color = TangemTheme.colors.background.primary, + shape = RoundedCornerShape( + topStart = TangemTheme.dimens.radius16, + topEnd = TangemTheme.dimens.radius16, + ), + ) .padding( start = TangemTheme.dimens.spacing12, top = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing4, ), - text = stringResourceSafe(R.string.nft_receive_choose_network), + text = text.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, ) @@ -74,32 +130,84 @@ private fun Preview_NFTReceiveNetworksUM() { TangemThemePreview { NFTReceiveNetworksContent( state = NFTReceiveUM.Networks.Content( - items = persistentListOf( + availableItems = persistentListOf( NFTNetworkUM( id = "item1", - name = "Ethereum", - iconRes = R.drawable.img_eth_22, + chainRowUM = ChainRowUM( + name = "Ethereum", + type = "", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_eth_22, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ), onItemClick = { }, ), NFTNetworkUM( id = "item2", - name = "Polygon", - iconRes = R.drawable.img_polygon_22, + chainRowUM = ChainRowUM( + name = "Polygon", + type = "", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_polygon_22, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ), onItemClick = { }, ), NFTNetworkUM( id = "item3", - name = "BSC", - iconRes = R.drawable.img_bsc_22, + chainRowUM = ChainRowUM( + name = "BSC", + type = "", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_bsc_22, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ), onItemClick = { }, ), NFTNetworkUM( id = "item4", - name = "Avalanche", - iconRes = R.drawable.img_avalanche_22, + chainRowUM = ChainRowUM( + name = "Avalanche", + type = "", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_avalanche_22, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ), + onItemClick = { }, + ), + NFTNetworkUM( + id = "item5", + chainRowUM = ChainRowUM( + name = "Avalanche", + type = "", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_avalanche_22, + isGrayscale = false, + showCustomBadge = true, + ), + showCustom = true, + ), onItemClick = { }, ), ), + unavailableItems = persistentListOf(), ), ) } diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt index cebaa5a91d..368e7fd8fc 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt @@ -1,7 +1,12 @@ package com.tangem.features.nft.receive.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding 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.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -9,10 +14,18 @@ import com.tangem.features.nft.impl.R @Composable internal fun NFTReceiveNetworksEmpty(modifier: Modifier = Modifier) { - Text( - modifier = modifier, - text = stringResourceSafe(id = R.string.nft_empty_search), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, - ) + Box( + modifier = modifier + .fillMaxSize() + .padding(TangemTheme.dimens.spacing16) + .background(TangemTheme.colors.background.secondary), + ) { + Text( + modifier = Modifier + .align(Alignment.Center), + text = stringResourceSafe(id = R.string.nft_empty_search), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/NFTAssetTraitsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/NFTAssetTraitsComponent.kt new file mode 100644 index 0000000000..ec54b5846d --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/NFTAssetTraitsComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.nft.traits + +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.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.features.nft.traits.model.NFTAssetTraitsModel +import com.tangem.features.nft.traits.ui.NFTAssetTraits +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +internal class NFTAssetTraitsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : ComposableContentComponent, AppComponentContext by context { + + private val model: NFTAssetTraitsModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + NFTAssetTraits(state) + } + + data class Params( + val nftAsset: NFTAsset, + val onBackClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt new file mode 100644 index 0000000000..1837fa3f30 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.nft.traits.entity + +data class NFTAssetTraitUM( + val id: String, + val name: String, + val value: String, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt new file mode 100644 index 0000000000..11b3fd9eea --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.nft.traits.entity + +import kotlinx.collections.immutable.ImmutableList + +data class NFTAssetTraitsUM( + val onBackClick: () -> Unit, + val traits: ImmutableList, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt new file mode 100644 index 0000000000..52ed09006f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt @@ -0,0 +1,42 @@ +package com.tangem.features.nft.traits.model + +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.nft.models.NFTAsset +import com.tangem.features.nft.traits.NFTAssetTraitsComponent +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@ModelScoped +internal class NFTAssetTraitsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: NFTAssetTraitsComponent.Params = paramsContainer.require() + + private val _state = MutableStateFlow( + value = NFTAssetTraitsUM( + traits = params.nftAsset.transform(), + onBackClick = { params.onBackClick() }, + ), + ) + + val state: StateFlow get() = _state + + private fun NFTAsset.transform(): ImmutableList = this + .traits + .mapIndexed { index, trait -> + NFTAssetTraitUM( + id = index.toString(), + name = trait.name, + value = trait.value, + ) + }.toPersistentList() +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt new file mode 100644 index 0000000000..dc9135f37c --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt @@ -0,0 +1,29 @@ +package com.tangem.features.nft.traits.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM + +@Composable +internal fun NFTAssetTrait(state: NFTAssetTraitUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + Text( + text = state.name, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4), + text = state.value, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt new file mode 100644 index 0000000000..f6e27276f2 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt @@ -0,0 +1,41 @@ +package com.tangem.features.nft.traits.ui + +import androidx.activity.compose.BackHandler +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.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.impl.R +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM + +@Composable +internal fun NFTAssetTraits(state: NFTAssetTraitsUM, modifier: Modifier = Modifier) { + BackHandler(onBack = state.onBackClick) + + Scaffold( + modifier = modifier, + containerColor = TangemTheme.colors.background.secondary, + topBar = { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + startButton = TopAppBarButtonUM( + iconRes = R.drawable.ic_back_24, + onIconClicked = state.onBackClick, + ), + title = stringResourceSafe(R.string.nft_traits_title), + ) + }, + content = { innerPadding -> + NFTAssetTraitsContent( + modifier = Modifier + .padding(innerPadding), + state = state, + ) + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt new file mode 100644 index 0000000000..c51f428b21 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt @@ -0,0 +1,94 @@ +package com.tangem.features.nft.traits.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun NFTAssetTraitsContent(state: NFTAssetTraitsUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier + .padding(TangemTheme.dimens.spacing16), + title = null, + contentHorizontalPadding = 0.dp, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()), + ) { + state.traits.forEach { trait -> + key(trait.id) { + NFTAssetTrait( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing8, + ), + state = trait, + ) + } + } + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetTraitsProvider::class) state: NFTAssetTraitsUM) { + TangemThemePreview { + NFTAssetTraitsContent( + state = state, + ) + } +} + +private class NFTAssetTraitsProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTAssetTraitsUM( + traits = persistentListOf( + NFTAssetTraitUM( + id = "1", + name = "Trait 1", + value = "Value", + ), + NFTAssetTraitUM( + id = "2", + name = "Trait 2", + value = "Value", + ), + NFTAssetTraitUM( + id = "3", + name = "Trait 3", + value = "Value", + ), + NFTAssetTraitUM( + id = "4", + name = "Trait 4", + value = "Value", + ), + NFTAssetTraitUM( + id = "5", + name = "Trait 5", + value = "Value", + ), + ), + onBackClick = { }, + ), + ), +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt index 79f9406a97..05e2c4fd39 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt @@ -7,14 +7,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.AsyncImage import coil.request.ImageRequest +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.features.onboarding.v2.impl.R @Composable -internal fun WalletCard(url: String?, modifier: Modifier = Modifier) { +internal fun WalletCard(artwork: ArtworkUM?, modifier: Modifier = Modifier) { AsyncImage( modifier = modifier, model = ImageRequest.Builder(LocalContext.current) - .data(url) + .data(artwork?.verifiedArtwork ?: artwork?.defaultUrl) .crossfade(true) .build(), placeholder = painterResource(R.drawable.card_placeholder_black), 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 e466f10765..b6a675a820 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 @@ -4,6 +4,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.replaceAll import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.models.scan.ProductType import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,7 +13,6 @@ 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.common.util.cardTypesResolver -import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager @@ -29,6 +29,7 @@ import com.tangem.features.onboarding.v2.entry.impl.analytics.OnboardingEntryEve import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.NonCancellable @@ -150,10 +151,16 @@ internal class OnboardingEntryModel @Inject constructor( if (askBiometryFeatureToggles.isAskForBiometryEnabled) { modelScope.launch { if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) { + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.BiometricScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks(doneMode)), ) } else { + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -163,6 +170,9 @@ internal class OnboardingEntryModel @Inject constructor( } } } else { + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -177,6 +187,9 @@ internal class OnboardingEntryModel @Inject constructor( ) : AskBiometryComponent.ModelCallbacks { override fun onAllowed() { analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.On)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -187,6 +200,9 @@ internal class OnboardingEntryModel @Inject constructor( override fun onDenied() { analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.Off)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -232,6 +248,12 @@ internal class OnboardingEntryModel @Inject constructor( } } + private inline fun doIfVisa(function: () -> Unit) { + if (params.scanResponse.productType == ProductType.Visa) { + function() + } + } + inner class OnboardingTwinModelCallbacks : OnboardingTwinComponent.ModelCallbacks { override fun onDone() { navigateToFinalScreenFlow() 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 2a35bf160f..c685d24d3c 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 @@ -6,6 +6,7 @@ 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.components.artwork.ArtworkUM import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ProductType import com.tangem.domain.onboarding.repository.OnboardingRepository @@ -16,6 +17,7 @@ import com.tangem.features.onboarding.v2.common.ui.interruptBackupDialog import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMultiWalletUM +import com.tangem.operations.attestation.ArtworkSize import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -78,18 +80,26 @@ internal class OnboardingMultiWalletModel @Inject constructor( modelScope.launch { backups.collectLatest { backup -> when { - _uiState.value.artwork2Url == null && backup.card2 != null -> { + _uiState.value.artwork2 == null && backup.card2 != null -> { val artwork = - getCardImageUseCase.invoke(backup.card2.cardId, backup.card2.cardPublicKey) + getCardImageUseCase.invoke( + cardId = backup.card2.cardId, + cardPublicKey = backup.card2.cardPublicKey, + size = ArtworkSize.LARGE, + ) _uiState.update { - it.copy(artwork2Url = artwork) + it.copy(artwork2 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } - _uiState.value.artwork3Url == null && backup.card3 != null -> { + _uiState.value.artwork3 == null && backup.card3 != null -> { val artwork = - getCardImageUseCase.invoke(backup.card3.cardId, backup.card3.cardPublicKey) + getCardImageUseCase.invoke( + cardId = backup.card3.cardId, + cardPublicKey = backup.card3.cardPublicKey, + size = ArtworkSize.LARGE, + ) _uiState.update { - it.copy(artwork3Url = artwork) + it.copy(artwork3 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } @@ -156,12 +166,14 @@ internal class OnboardingMultiWalletModel @Inject constructor( private fun loadCardArtwork() { modelScope.launch { val artwork = - getCardImageUseCase.invoke(params.scanResponse.card.cardId, params.scanResponse.card.cardPublicKey) + getCardImageUseCase.invoke( + cardId = params.scanResponse.card.cardId, + cardPublicKey = params.scanResponse.card.cardPublicKey, + size = ArtworkSize.LARGE, + ) - if (artwork != getCardImageUseCase.getDefaultFallbackUrl()) { - _uiState.update { - it.copy(artwork1Url = artwork) - } + _uiState.update { + it.copy(artwork1 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt index 4af97756ab..00b794fd58 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt @@ -25,9 +25,9 @@ internal fun OnboardingMultiWallet( ) { if (isSeedPhraseState.not()) { WalletArtworks( - url1 = state.artwork1Url, - url2 = state.artwork2Url, - url3 = state.artwork3Url, + artwork1 = state.artwork1, + artwork2 = state.artwork2, + artwork3 = state.artwork3, modifier = Modifier .padding(horizontal = 34.dp) .padding(top = 20.dp) @@ -53,7 +53,7 @@ internal fun OnboardingMultiWallet( private fun Preview() { TangemThemePreview { OnboardingMultiWallet( - state = OnboardingMultiWalletUM(artwork1Url = null), + state = OnboardingMultiWalletUM(artwork1 = null), artworksState = WalletArtworksState.Folded, isSeedPhraseState = false, childContent = { md -> @@ -73,7 +73,7 @@ private fun Preview() { private fun Preview2() { TangemThemePreview { OnboardingMultiWallet( - state = OnboardingMultiWalletUM(artwork1Url = null), + state = OnboardingMultiWalletUM(artwork1 = null), artworksState = WalletArtworksState.Folded, isSeedPhraseState = true, childContent = { md -> diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt index cb4b29a905..d199e807dc 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.zIndex +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.common.ui.WalletCard @@ -57,9 +58,9 @@ sealed class WalletArtworksState { @Composable fun WalletArtworks( - url1: String?, - url2: String?, - url3: String?, + artwork1: ArtworkUM?, + artwork2: ArtworkUM?, + artwork3: ArtworkUM?, state: WalletArtworksState, modifier: Modifier = Modifier, ) { @@ -120,9 +121,9 @@ fun WalletArtworks( } AnimatedWalletCards( - url1 = url1, - url2 = url2, - url3 = url3, + artwork1 = artwork1, + artwork2 = artwork2, + artwork3 = artwork3, modifier = Modifier .widthIn(max = 450.dp) .matchParentSize(), @@ -152,9 +153,9 @@ private data class WalletCardTransitionState( @Suppress("LongParameterList") @Composable private fun AnimatedWalletCards( - url1: String?, - url2: String?, - url3: String?, + artwork1: ArtworkUM?, + artwork2: ArtworkUM?, + artwork3: ArtworkUM?, transition1: Transition, transition2: Transition, transition3: Transition, @@ -199,7 +200,7 @@ private fun AnimatedWalletCards( scaleY = scaleY3 rotationZ = rotation3 }, - url = url3, + artwork = artwork3, ) WalletCard( @@ -217,7 +218,7 @@ private fun AnimatedWalletCards( scaleX = scaleX2 scaleY = scaleY2 }, - url = url2, + artwork = artwork2, ) WalletCard( @@ -234,7 +235,7 @@ private fun AnimatedWalletCards( translationY = translationY1 rotationZ = rotation1 }, - url = url1, + artwork = artwork1, ) } } @@ -447,9 +448,9 @@ private fun Preview() { var state: WalletArtworksState by remember { mutableStateOf(WalletArtworksState.Folded) } WalletArtworks( - url1 = null, - url2 = null, - url3 = null, + artwork1 = null, + artwork2 = null, + artwork3 = null, state = state, modifier = Modifier.fillMaxWidth(), ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt index 829828761d..3220607f0f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt @@ -1,7 +1,9 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM + internal data class OnboardingMultiWalletUM( - val artwork1Url: String? = null, - val artwork2Url: String? = null, - val artwork3Url: String? = null, + val artwork1: ArtworkUM? = null, + val artwork2: ArtworkUM? = null, + val artwork3: ArtworkUM? = null, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt index 59f7b2e7ab..ce79faeb51 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.Analytics 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.artwork.ArtworkUM import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletBuilder @@ -93,9 +94,11 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor( private fun observeArtwork() { modelScope.launch { - params.childParams.commonState.collect { + params.childParams.commonState.collect { state -> _uiState.value = _uiState.value.copy( - artworkUrl = it.cardArtworkUrl, + artwork = state.cardArtwork?.let { + ArtworkUM(it.verifiedArtwork, it.defaultUrl) + }, ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt index f12d83941a..91644d77bb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt @@ -32,9 +32,9 @@ internal fun OnboardingNoteCreateWallet(state: OnboardingNoteCreateWalletUM, mod verticalArrangement = Arrangement.Bottom, ) { WalletArtworks( - url1 = state.artworkUrl, - url2 = null, - url3 = null, + artwork1 = state.artwork, + artwork2 = null, + artwork3 = null, modifier = Modifier .padding(top = 64.dp) .padding(horizontal = 24.dp) @@ -86,7 +86,7 @@ private fun OnboardingNoteCreatePreview() { TangemThemePreview { OnboardingNoteCreateWallet( state = OnboardingNoteCreateWalletUM( - artworkUrl = null, + artwork = null, createWalletInProgress = false, onCreateClick = {}, ), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt index 950445d00d..d11347b973 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt @@ -1,7 +1,9 @@ package com.tangem.features.onboarding.v2.note.impl.child.create.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM + data class OnboardingNoteCreateWalletUM( - val artworkUrl: String? = null, + val artwork: ArtworkUM? = null, val createWalletInProgress: Boolean = false, val onCreateClick: () -> Unit = {}, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt index 021509cd04..bdac9d90ca 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt @@ -129,7 +129,7 @@ internal class OnboardingNoteTopUpModel @Inject constructor( modelScope.launch { params.childParams.commonState.collect { _uiState.value = _uiState.value.copy( - cardArtworkUrl = it.cardArtworkUrl, + cardArtwork = it.cardArtwork, ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt index 64125adc75..9830905a93 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalTangemShimmer import com.tangem.core.ui.res.TangemTheme @@ -21,11 +22,12 @@ import com.tangem.features.onboarding.v2.common.ui.RefreshButton import com.tangem.features.onboarding.v2.common.ui.WalletCard import com.tangem.features.onboarding.v2.impl.R import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.ImmutableList @Composable fun OnboardingNoteTopUpHeader( balance: String, - cardArtworkUrl: String?, + cardArtwork: ArtworkUM?, isRefreshing: Boolean, onRefreshBalanceClick: () -> Unit, modifier: Modifier = Modifier, @@ -77,7 +79,7 @@ fun OnboardingNoteTopUpHeader( } WalletCard( modifier = Modifier.width(120.dp).align(Alignment.TopCenter), - url = cardArtworkUrl, + artwork = cardArtwork, ) RefreshButton( modifier = Modifier.align(Alignment.BottomCenter), @@ -93,7 +95,7 @@ private fun OnboardinNoteTopUpHeaderPreview() { TangemThemePreview { OnboardingNoteTopUpHeader( balance = "0.00000001 BTC", - cardArtworkUrl = "", + cardArtwork = ArtworkUM(null as ImmutableList?, ""), onRefreshBalanceClick = {}, isRefreshing = false, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt index b7bee01049..476d06b378 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt @@ -34,7 +34,7 @@ fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modif ) { OnboardingNoteTopUpHeader( balance = state.balance, - cardArtworkUrl = state.cardArtworkUrl, + cardArtwork = state.cardArtwork, onRefreshBalanceClick = state.onRefreshBalanceClick, isRefreshing = state.isRefreshing, modifier = Modifier diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt index 327de0d586..c9d9ca3541 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt @@ -1,9 +1,10 @@ package com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig data class OnboardingNoteTopUpUM( - val cardArtworkUrl: String? = null, + val cardArtwork: ArtworkUM? = null, val availableForBuy: Boolean = false, val availableForBuyLoading: Boolean = true, val balance: String = "", diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt index 8ebe5bcd8b..740f471257 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.note.impl.model +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet @@ -7,6 +8,6 @@ import com.tangem.domain.wallets.models.UserWallet internal data class OnboardingNoteCommonState( val scanResponse: ScanResponse, val userWallet: UserWallet? = null, - val cardArtworkUrl: String? = null, + val cardArtwork: ArtworkUM? = null, val cryptoCurrencyStatus: CryptoCurrencyStatus? = null, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt index 30733d3ff8..43303a39b2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt @@ -6,6 +6,7 @@ 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.components.artwork.ArtworkUM import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.features.onboarding.v2.common.ui.exitOnboardingDialog @@ -13,6 +14,7 @@ import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent import com.tangem.features.onboarding.v2.note.impl.OnboardingNoteInnerNavigationState import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute import com.tangem.features.onboarding.v2.note.impl.route.stepNum +import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -50,7 +52,7 @@ internal class OnboardingNoteModel @Inject constructor( val commonUiState: MutableStateFlow = _uiState init { - loadArtworkUrl() + loadArtwork() } fun updateStepForNewRoute(route: OnboardingNoteRoute) { @@ -85,15 +87,16 @@ internal class OnboardingNoteModel @Inject constructor( } } - private fun loadArtworkUrl() { + private fun loadArtwork() { modelScope.launch { val cardInfo = params.scanResponse.card - val url = getCardImageUseCase( + val artwork = getCardImageUseCase.invoke( cardId = cardInfo.cardId, cardPublicKey = cardInfo.cardPublicKey, + size = ArtworkSize.LARGE, ) _uiState.update { - it.copy(cardArtworkUrl = url) + it.copy(cardArtwork = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } 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 640ac88a7f..5bedacb9ee 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 @@ -9,6 +9,7 @@ import com.arkivanov.essenty.instancekeeper.getOrCreateSimple 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.common.TapWorkarounds.isVisa import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType @@ -40,7 +41,13 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor( componentScope.launch { val cardInfo = getCardInfoUseCase(params.scanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) + sendFeedbackEmailUseCase( + if (params.scanResponse.card.isVisa) { + FeedbackEmailType.Visa.Activation(cardInfo) + } else { + FeedbackEmailType.DirectUserRequest(cardInfo) + }, + ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt index 5fba3214f8..97758be4e1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.util.fastForEach import androidx.compose.ui.zIndex import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -212,7 +213,7 @@ private fun AnimatedTwinCards( scaleX = scaleX2 scaleY = scaleY2 }, - url = Artwork.TWIN_CARD_2_URL, + artwork = ArtworkUM(defaultUrl = Artwork.TWIN_CARD_2_URL), ) WalletCard( @@ -229,7 +230,7 @@ private fun AnimatedTwinCards( translationY = translationY1 rotationZ = rotation1 }, - url = Artwork.TWIN_CARD_1_URL, + artwork = ArtworkUM(defaultUrl = Artwork.TWIN_CARD_1_URL), ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt index b3cc93357a..047c16e1d5 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt @@ -167,7 +167,9 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( params = OnboardingVisaOtherWalletComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.pushNew(OnboardingVisaRoute.PinCode(activationOrderInfo = it)) + model.stackNavigation.replaceAll( + OnboardingVisaRoute.PinCode(activationOrderInfo = it, pinCodeValidationError = false), + ) }, ), ) @@ -176,11 +178,12 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( config = OnboardingVisaPinCodeComponent.Config( scanResponse = model.currentScanResponse.value, activationOrderInfo = route.activationOrderInfo, + wasValidationError = route.pinCodeValidationError, ), params = OnboardingVisaPinCodeComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.pushNew( + model.stackNavigation.replaceAll( OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode), ) }, @@ -196,7 +199,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( params = OnboardingVisaApproveComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.pushNew( + model.stackNavigation.replaceAll( OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve), ) }, 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 71c5adec28..6c1dcc74e1 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -19,6 +20,8 @@ import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.repository.VisaAuthRepository 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 +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.common.ActivationReadyEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.visa.VisaCardActivationTaskMode @@ -30,6 +33,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class OnboardingVisaAccessCodeModel @Inject constructor( @@ -40,6 +44,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( private val tangemSdkManager: TangemSdkManager, private val visaAuthRepository: VisaAuthRepository, private val uiMessageSender: UiMessageSender, + private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { private val params: OnboardingVisaAccessCodeComponent.Config = paramsContainer.require() @@ -49,9 +54,12 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( cardPublicKey = params.scanResponse.card.cardPublicKey.toHexString(), ), ) - private val activationStatus = - params.scanResponse.visaCardActivationStatus as? VisaCardActivationStatus.NotStartedActivation - ?: error("Visa activation status is not set or incorrect for this step") + + private val activationInput = when (val status = params.scanResponse.visaCardActivationStatus) { + is VisaCardActivationStatus.NotStartedActivation -> status.activationInput + is VisaCardActivationStatus.ActivationStarted -> status.activationInput + else -> error("Visa activation status is not set or incorrect for this step") + } private val _uiState = MutableStateFlow(getInitialState()) @@ -59,6 +67,10 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( val onBack = MutableSharedFlow() val onDone = MutableSharedFlow() + init { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.SettingAccessCodeStarted) + } + fun onBack() { if (uiState.value.buttonLoading) return @@ -82,6 +94,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( _uiState.update { it.copy( accessCodeFirst = textFieldValue, + accessCodeSecond = TextFieldValue(), atLeastMinCharsError = false, ) } @@ -99,11 +112,14 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( private fun onContinue() { when (uiState.value.step) { OnboardingVisaAccessCodeUM.Step.Enter -> { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.AccessCodeEntered) if (checkAccessCodeMinChars().not()) return _uiState.update { it.copy(step = OnboardingVisaAccessCodeUM.Step.ReEnter) } + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.AccessCodeReenterScreen) } OnboardingVisaAccessCodeUM.Step.ReEnter -> { if (checkAccessCodesMatch().not()) return + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.OnboardingVisa) startActivationProcess(accessCode = uiState.value.accessCodeFirst.text) } } @@ -139,8 +155,8 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( modelScope.launch { val challengeToSign = runCatching { visaAuthRepository.getCardAuthChallenge( - cardId = activationStatus.activationInput.cardId, - cardPublicKey = activationStatus.activationInput.cardPublicKey, + cardId = activationInput.cardId, + cardPublicKey = activationInput.cardPublicKey, ) }.getOrElse { loading(false) @@ -153,13 +169,16 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( accessCode = accessCode, authorizationChallenge = challengeToSign, ), - activationInput = activationStatus.activationInput, + activationInput = activationInput, ) val resultData = when (result) { is CompletionResult.Failure -> { loading(false) uiMessageSender.showErrorDialog(result.error.universalError) + analyticsEventsHandler.send( + VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError), + ) return@launch } is CompletionResult.Success -> result.data @@ -173,14 +192,16 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( return@launch } - val targetAddress = result.data.signedActivationData.dataToSign.request.customerWalletAddress + val targetAddress = + result.data.signedActivationData.dataToSign.request.activationOrderInfo.customerWalletAddress modelScope.launch { onDone.emit( ActivationReadyEvent( customerWalletDataToSignRequest = VisaCustomerWalletDataToSignRequest( - orderId = result.data.signedActivationData.dataToSign.request.orderId, - cardWalletAddress = result.data.signedActivationData.cardWalletAddress, + orderId = result.data.signedActivationData.dataToSign.request.activationOrderInfo.orderId, + cardWalletAddress = result.data.signedActivationData.dataToSign.request.cardWalletAddress, + customerWalletAddress = targetAddress, ), newScanResponse = params.scanResponse.copy( card = result.data.newCardDTO, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt index a4803e73d0..31f7aa82ed 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.approve.model import androidx.compose.runtime.Stable import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -15,6 +16,8 @@ import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.features.onboarding.v2.visa.impl.child.approve.OnboardingVisaApproveComponent import com.tangem.features.onboarding.v2.visa.impl.child.approve.ui.state.OnboardingVisaApproveUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableSharedFlow @@ -32,6 +35,7 @@ internal class OnboardingVisaApproveModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val uiMessageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -47,6 +51,10 @@ internal class OnboardingVisaApproveModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.WalletPrepare) + } + private fun getInitialState(): OnboardingVisaApproveUM { return OnboardingVisaApproveUM( onApproveClick = ::onApproveClick, @@ -56,6 +64,8 @@ internal class OnboardingVisaApproveModel @Inject constructor( private fun onApproveClick() { loading(true) + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonApprove) + modelScope.launch { val dataToSign = runCatching { visaActivationRepository.getCustomerWalletAcceptanceData(params.preparationDataForApprove.request) @@ -77,6 +87,7 @@ internal class OnboardingVisaApproveModel @Inject constructor( is CompletionResult.Failure -> { loading(false) uiMessageSender.showErrorDialog(result.error.universalError) + analyticsEventHandler.send(VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError)) return@launch } is CompletionResult.Success -> result.data diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt index 3d48e57718..b9d427dc44 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.ui.extensions.resourceReference @@ -8,6 +9,7 @@ import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.OnboardingVisaChooseWalletComponent.Params.Event import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui.state.OnboardingVisaChooseWalletUM import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui.state.SelectableChainRowUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow @@ -21,6 +23,7 @@ import javax.inject.Inject @ModelScoped internal class OnboardingVisaChooseWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val _uiState = MutableStateFlow(getInitialState()) @@ -53,7 +56,18 @@ internal class OnboardingVisaChooseWalletModel @Inject constructor( private fun onContinueClicked() { modelScope.launch { val selectedOption = _uiState.value.selectedOption ?: return@launch + val value = getAnalyticsValue(selectedOption.event) + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ChooseWalletScreen(value)) onEvent.emit(selectedOption.event) } } + + private fun getAnalyticsValue(event: Event) = when (event) { + Event.TangemWallet -> { + "Tangem" + } + Event.OtherWallet -> { + "Other" + } + } } \ No newline at end of file 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 b8d608e1d3..c0fdbbd57d 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 @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.inprogress.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -20,6 +21,8 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.route.OnboardingVisaRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.delay @@ -41,6 +44,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val userWalletsListManager: UserWalletsListManager, private val uiMessageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -53,10 +57,15 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val onDone = MutableSharedFlow() init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ActivationInProgressScreen) + runShortPolling() + } + + private fun runShortPolling() { modelScope.launch { while (true) { val result = runCatching { - visaActivationRepository.getActivationRemoteStateLongPoll() + visaActivationRepository.getActivationRemoteState() }.getOrNull() ?: continue when (result) { @@ -64,6 +73,16 @@ internal class OnboardingVisaInProgressModel @Inject constructor( VisaActivationRemoteState.BlockedForActivation, -> { uiMessageSender.showErrorDialog(VisaActivationError.InconsistentRemoteState) + analyticsEventHandler.send( + VisaAnalyticsEvent.ErrorOnboarding(VisaActivationError.InconsistentRemoteState), + ) + return@launch + } + VisaActivationRemoteState.Failed -> { + uiMessageSender.showErrorDialog(VisaActivationError.FailedRemoteState) + analyticsEventHandler.send( + VisaAnalyticsEvent.ErrorOnboarding(VisaActivationError.FailedRemoteState), + ) return@launch } is VisaActivationRemoteState.CustomerWalletSignatureRequired, @@ -71,9 +90,10 @@ internal class OnboardingVisaInProgressModel @Inject constructor( VisaActivationRemoteState.WaitingForActivationFinishing, -> { } - is VisaActivationRemoteState.WaitingPinCode -> { - navigateToPinCode(result.activationOrderInfo) - return@launch + is VisaActivationRemoteState.AwaitingPinCode -> { + navigateToPinCodeIfNeeded(result) { + return@launch + } } VisaActivationRemoteState.Activated -> { finishActivation() @@ -81,13 +101,42 @@ internal class OnboardingVisaInProgressModel @Inject constructor( } } - delay(timeMillis = 1000) + delay(timeMillis = 2000) } } } - private suspend fun navigateToPinCode(activationOrderInfo: VisaActivationOrderInfo) { - onDone.emit(Params.DoneEvent.NavigateTo(OnboardingVisaRoute.PinCode(activationOrderInfo))) + private suspend inline fun navigateToPinCodeIfNeeded( + remoteState: VisaActivationRemoteState.AwaitingPinCode, + complete: () -> Unit, + ) { + when (remoteState.status) { + VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode -> { + onDone.emit( + Params.DoneEvent.NavigateTo( + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = false, + ), + ), + ) + complete() + } + VisaActivationRemoteState.AwaitingPinCode.Status.WasError -> { + onDone.emit( + Params.DoneEvent.NavigateTo( + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = true, + ), + ), + ) + complete() + } + VisaActivationRemoteState.AwaitingPinCode.Status.InProgress -> { + /** waiting for the new state */ + } + } } private suspend fun finishActivation() { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt index 875e5a4b49..28fca46909 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -13,6 +14,7 @@ import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.OnboardingVisaOtherWalletComponent import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.ui.state.OnboardingVisaOtherWalletUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,6 +31,7 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, private val shareManager: ShareManager, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val config = paramsContainer.require() @@ -44,18 +47,19 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( val onDone = MutableSharedFlow() init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.GoToWebsiteOpened) modelScope.launch { while (true) { val result = runCatching { - visaActivationRepository.getActivationRemoteStateLongPoll() + visaActivationRepository.getActivationRemoteState() }.getOrNull() - if (result is VisaActivationRemoteState.WaitingPinCode) { + if (result is VisaActivationRemoteState.AwaitingPinCode) { onDone.emit(result.activationOrderInfo) break } - delay(timeMillis = 1000) + delay(timeMillis = 2000) } } } @@ -68,10 +72,12 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( } private fun onShareClicked() { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonShareLink) shareManager.shareText("https://tangem.com/") // TODO } private fun onOpenInBrowserClicked() { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonBrowser) urlOpener.openUrl("https://tangem.com/") // TODO } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt index b70d2995a1..baf52ede33 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt @@ -48,6 +48,7 @@ internal class OnboardingVisaPinCodeComponent( data class Config( val scanResponse: ScanResponse, val activationOrderInfo: VisaActivationOrderInfo, + val wasValidationError: Boolean, ) data class Params( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt index bcb96ade2f..3deb78230c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt @@ -2,28 +2,26 @@ package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler 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.datasource.local.visa.VisaAuthTokenStorage -import com.tangem.domain.models.scan.ScanResponse +import com.tangem.core.ui.utils.showErrorDialog import com.tangem.domain.visa.SetVisaPinCodeUseCase -import com.tangem.domain.visa.model.VisaCardActivationStatus +import com.tangem.domain.visa.error.VisaAuthorizationAPIError import com.tangem.domain.visa.model.VisaCardId -import com.tangem.domain.wallets.builder.UserWalletBuilder -import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.visa.impl.child.pincode.OnboardingVisaPinCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider 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 kotlinx.coroutines.withContext import javax.inject.Inject @Stable @@ -32,11 +30,9 @@ import javax.inject.Inject internal class OnboardingVisaPinCodeModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val userWalletBuilderFactory: UserWalletBuilder.Factory, - private val userWalletsListManager: UserWalletsListManager, - private val authTokenStorage: VisaAuthTokenStorage, - private val otpStorage: VisaAuthTokenStorage, private val setVisaPinCodeUseCase: SetVisaPinCodeUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -49,11 +45,20 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.PinCodeScreenOpened) + } + private fun getInitialState(): OnboardingVisaPinCodeUM { return OnboardingVisaPinCodeUM( submitButtonEnabled = false, onPinCodeChange = ::onPinCodeChange, onSubmitClick = ::onSubmitClick, + error = if (params.wasValidationError) { + resourceReference(R.string.visa_onboarding_pin_not_accepted) + } else { + null + }, ) } @@ -66,6 +71,7 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( pinCode = pin, submitButtonEnabled = PinCodeValidation.validate(pin), error = if (isError) { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ErrorPinValidation) resourceReference(R.string.visa_onboarding_pin_validation_error_message) } else { null @@ -76,6 +82,7 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( } private fun onSubmitClick() { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.PinEntered) val pinCode = _uiState.value.pinCode if (PinCodeValidation.validate(pinCode).not()) return @@ -88,11 +95,11 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( activationOrderId = params.activationOrderInfo.orderId, ).onLeft { loading(false) + uiMessageSender.showErrorDialog(VisaAuthorizationAPIError) return@launch } - saveWallet() - + onDone.emit(Unit) loading(false) } } @@ -100,26 +107,4 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( private fun loading(state: Boolean) { _uiState.update { it.copy(submitButtonLoading = state) } } - - private suspend fun saveWallet() { - val userWallet = createUserWallet(params.scanResponse) - userWalletsListManager.save(userWallet) - authTokenStorage.remove(params.scanResponse.card.cardId) - otpStorage.remove(params.scanResponse.card.cardId) - onDone.emit(Unit) - } - - private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet = withContext(dispatchers.io) { - val newActivationStatus = VisaCardActivationStatus.Activated( - visaAuthTokens = authTokenStorage.get(scanResponse.card.cardId) - ?: error("Impossible state. Wrong feature implementation"), - ) - - requireNotNull( - value = userWalletBuilderFactory.create( - scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus), - ).build(), - lazyMessage = { "User wallet not created" }, - ) - } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/PinCodeValidation.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/PinCodeValidation.kt index 99c721134d..4e026f18ec 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/PinCodeValidation.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/PinCodeValidation.kt @@ -22,10 +22,11 @@ internal object PinCodeValidation { } private fun validateNoRepeatedDigits(pinCode: String): Boolean { - return pinCode.toSet().size == PIN_CODE_LENGTH + return pinCode.toSet().size > 1 } private fun validateNoConsecutiveDigits(pinCode: String): Boolean { - return pinCode.windowed(2).all { it[0] + 1 == it[1] }.not() + return pinCode.zipWithNext().any { it.second != it.first + 1 } && + pinCode.zipWithNext().any { it.second != it.first - 1 } } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt index db3f28bd1a..7c602c0e14 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -14,6 +15,8 @@ import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.Config import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.DoneEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.child.welcome.ui.state.OnboardingVisaWelcomeUM import com.tangem.features.onboarding.v2.visa.impl.common.ActivationReadyEvent import com.tangem.sdk.api.TangemSdkManager @@ -33,6 +36,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( private val tangemSdkManager: TangemSdkManager, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, private val uiMessageSender: UiMessageSender, + private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -42,6 +46,10 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.ActivationScreenOpened) + } + private fun getInitialState(): OnboardingVisaWelcomeUM { return OnboardingVisaWelcomeUM( mode = if (params is Config.WelcomeBack) { @@ -54,6 +62,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( } private fun onContinueClick() { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.ButtonActivate) if (params !is Config.WelcomeBack) { modelScope.launch { onDone.emit(DoneEvent.WelcomeDone) } return @@ -86,6 +95,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( is CompletionResult.Failure -> { loading(false) uiMessageSender.showErrorDialog(result.error.universalError) + analyticsEventsHandler.send(VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError)) return@launch } is CompletionResult.Success -> result.data @@ -99,15 +109,17 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( return@launch } - val targetAddress = result.data.signedActivationData.dataToSign.request.customerWalletAddress + val request = result.data.signedActivationData.dataToSign.request + val targetAddress = request.activationOrderInfo.customerWalletAddress modelScope.launch { onDone.emit( DoneEvent.WelcomeBackDone( ActivationReadyEvent( customerWalletDataToSignRequest = VisaCustomerWalletDataToSignRequest( - orderId = result.data.signedActivationData.dataToSign.request.orderId, - cardWalletAddress = result.data.signedActivationData.cardWalletAddress, + orderId = request.activationOrderInfo.orderId, + cardWalletAddress = request.cardWalletAddress, + customerWalletAddress = targetAddress, ), newScanResponse = params.scanResponse.copy( card = result.data.newCardDTO, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt new file mode 100644 index 0000000000..25759dce3a --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt @@ -0,0 +1,85 @@ +package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal sealed class OnboardingVisaAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Onboarding / Visa", event, params) { + + data object ActivationScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Activation Screen Opened", + ) + + data object ButtonActivate : OnboardingVisaAnalyticsEvent( + event = "Button - Activate", + ) + + data object SettingAccessCodeStarted : OnboardingVisaAnalyticsEvent( + event = "Setting Access Code Started", + ) + + data object AccessCodeEntered : OnboardingVisaAnalyticsEvent( + event = "Access Code Entered", + ) + + data object AccessCodeReenterScreen : OnboardingVisaAnalyticsEvent( + event = "Access Code Re-enter Screen", + ) + + data object OnboardingVisa : OnboardingVisaAnalyticsEvent( + event = "Onboarding / Visa", + ) + + data class ChooseWalletScreen( + val type: String, + ) : OnboardingVisaAnalyticsEvent( + event = "Choose Wallet Screen", + params = mapOf("Type" to type), + ) + + data object WalletPrepare : OnboardingVisaAnalyticsEvent( + event = "Wallet Prepare", + ) + + data object ButtonApprove : OnboardingVisaAnalyticsEvent( + event = "Button - Approve", + ) + + data object GoToWebsiteOpened : OnboardingVisaAnalyticsEvent( + event = "Go To Website Opened", + ) + + data object ButtonBrowser : OnboardingVisaAnalyticsEvent( + event = "Button - Browser", + ) + + data object ButtonShareLink : OnboardingVisaAnalyticsEvent( + event = "Button - Share Link", + ) + + data object ActivationInProgressScreen : OnboardingVisaAnalyticsEvent( + event = "Activation In Progress Screen", + ) + + data object PinCodeScreenOpened : OnboardingVisaAnalyticsEvent( + event = "PIN Code Screen Opened", + ) + + data object PinEntered : OnboardingVisaAnalyticsEvent( + event = "PIN Entered", + params = mapOf("Type" to "Visa"), + ) + + data object BiometricScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Biometric Screen Opened", + ) + + data object SuccessScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Success Screen Opened", + ) + + data object ErrorPinValidation : OnboardingVisaAnalyticsEvent( + event = "Error - Pin Validation", + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt new file mode 100644 index 0000000000..e77e0bb36f --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.error.UniversalError + +internal const val ONBOARDING_SOURCE = "Onboarding" +internal const val MAIN_SOURCE = "Main" + +internal sealed class VisaAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Visa", event, params) { + + data class ErrorOnboarding(val error: UniversalError) : VisaAnalyticsEvent( + event = "Errors", + params = mapOf( + "Error Code" to error.errorCode.toString(), + "Source" to ONBOARDING_SOURCE, + ), + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt index f93540c8ee..9961ec2459 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt @@ -1,10 +1,10 @@ package com.tangem.features.onboarding.v2.visa.impl.model import androidx.compose.runtime.Stable -import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,6 +12,7 @@ import com.tangem.domain.common.visa.VisaUtilities import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.domain.visa.model.VisaCardActivationStatus +import com.tangem.domain.visa.model.VisaCardWalletDataToSignRequest import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onboarding.v2.visa.api.OnboardingVisaComponent @@ -83,14 +84,13 @@ internal class OnboardingVisaModel @Inject constructor( ) } - @OptIn(ExperimentalDecomposeApi::class) fun navigateFromInProgress(event: OnboardingVisaInProgressComponent.Params.DoneEvent) { when (event) { OnboardingVisaInProgressComponent.Params.DoneEvent.Activated -> { modelScope.launch { onDone.emit(Unit) } } is OnboardingVisaInProgressComponent.Params.DoneEvent.NavigateTo -> { - stackNavigation.pushNew(event.route) + stackNavigation.replaceAll(event.route) } } } @@ -145,10 +145,17 @@ internal class OnboardingVisaModel @Inject constructor( is VisaCardActivationStatus.ActivationStarted -> { when (val remoteState = activationStatus.remoteState) { is VisaActivationRemoteState.CardWalletSignatureRequired -> { - OnboardingVisaRoute.WelcomeBack( - activationInput = activationStatus.activationInput, - dataToSignByCardWalletRequest = remoteState.request, - ) + if (activationStatus.activationInput.isAccessCodeSet) { + OnboardingVisaRoute.WelcomeBack( + activationInput = activationStatus.activationInput, + dataToSignByCardWalletRequest = VisaCardWalletDataToSignRequest( + activationOrderInfo = remoteState.activationOrderInfo, + cardWalletAddress = activationStatus.cardWalletAddress, + ), + ) + } else { + OnboardingVisaRoute.AccessCode + } } is VisaActivationRemoteState.CustomerWalletSignatureRequired -> { remoteState.getRoute(activationStatus) @@ -159,11 +166,15 @@ internal class OnboardingVisaModel @Inject constructor( VisaActivationRemoteState.WaitingForActivationFinishing -> { OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode) } - is VisaActivationRemoteState.WaitingPinCode -> { - OnboardingVisaRoute.PinCode(activationOrderInfo = remoteState.activationOrderInfo) + is VisaActivationRemoteState.AwaitingPinCode -> { + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = false, + ) } VisaActivationRemoteState.Activated, VisaActivationRemoteState.BlockedForActivation, + VisaActivationRemoteState.Failed, -> error("Activation status is not correct for onboarding flow") } } @@ -179,6 +190,7 @@ internal class OnboardingVisaModel @Inject constructor( val request = VisaCustomerWalletDataToSignRequest( orderId = this.activationOrderInfo.orderId, cardWalletAddress = activationStatus.cardWalletAddress, + customerWalletAddress = this.activationOrderInfo.customerWalletAddress, ) val preparationDataForApprove = PreparationDataForApprove( customerWalletAddress = this.activationOrderInfo.customerWalletAddress, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt index 9dca6484e9..762fc96544 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt @@ -48,5 +48,8 @@ internal sealed class OnboardingVisaRoute { } @Serializable - data class PinCode(val activationOrderInfo: VisaActivationOrderInfo) : OnboardingVisaRoute() + data class PinCode( + val activationOrderInfo: VisaActivationOrderInfo, + val pinCodeValidationError: Boolean, + ) : OnboardingVisaRoute() } \ No newline at end of file diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampSuccessComponent.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampSuccessComponent.kt index 02280d517a..ceed88f9c5 100644 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampSuccessComponent.kt +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampSuccessComponent.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent interface OnrampSuccessComponent : ComposableContentComponent { - data class Params(val externalTxId: String) + data class Params(val txId: String) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ConfirmResidencyComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ConfirmResidencyComponent.kt index 7dda243f37..f948424bfa 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ConfirmResidencyComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ConfirmResidencyComponent.kt @@ -4,10 +4,12 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.onramp.model.OnrampCountry import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId internal interface ConfirmResidencyComponent : ComposableBottomSheetComponent { data class Params( + val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val country: OnrampCountry, val onDismiss: () -> Unit, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/DefaultConfirmResidencyComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/DefaultConfirmResidencyComponent.kt index 2ff2e6a708..053fa306c7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/DefaultConfirmResidencyComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/DefaultConfirmResidencyComponent.kt @@ -69,6 +69,7 @@ internal class DefaultConfirmResidencyComponent @AssistedInject constructor( is ConfirmResidencyBottomSheetConfig.SelectCountry -> selectCountryComponentFactory.create( context = childByContext(componentContext), params = SelectCountryComponent.Params( + userWalletId = params.userWalletId, cryptoCurrency = params.cryptoCurrency, onDismiss = { isCountrySelected -> // Dismiss country select sheet diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt index f6c03525c1..0f959bb2c2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt @@ -54,6 +54,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor( is OnrampMainBottomSheetConfig.ConfirmResidency -> confirmResidencyComponentFactory.create( context = childByContext(componentContext), params = ConfirmResidencyComponent.Params( + userWalletId = params.userWalletId, cryptoCurrency = params.cryptoCurrency, country = config.country, onDismiss = { @@ -65,6 +66,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor( is OnrampMainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create( context = childByContext(componentContext), params = SelectCurrencyComponent.Params( + userWallet = model.userWallet, cryptoCurrency = params.cryptoCurrency, onDismiss = model.bottomSheetNavigation::dismiss, ), 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 a3afe0f26f..09feb75a0d 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 @@ -57,6 +57,9 @@ internal class OnrampMainComponentModel @Inject constructor( ) : Model(), OnrampIntents { private val params: OnrampMainComponent.Params = paramsContainer.require() + + val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + private val stateFactory = OnrampStateFactory( currentStateProvider = Provider { _state.value }, cryptoCurrency = params.cryptoCurrency, @@ -68,7 +71,6 @@ internal class OnrampMainComponentModel @Inject constructor( onrampIntents = this, cryptoCurrency = params.cryptoCurrency, ) - private val selectedUserWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val _state: MutableStateFlow = MutableStateFlow( value = stateFactory.getInitialState( currency = params.cryptoCurrency.name, @@ -107,7 +109,7 @@ internal class OnrampMainComponentModel @Inject constructor( private fun checkResidenceCountry() { modelScope.launch { - checkOnrampAvailabilityUseCase() + checkOnrampAvailabilityUseCase(userWallet) .onRight(::handleOnrampAvailability) .onLeft(::handleOnrampError) } @@ -158,7 +160,7 @@ internal class OnrampMainComponentModel @Inject constructor( if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { _state.update { amountStateFactory.getAmountSecondaryLoadingState() } } - fetchPairsUseCase.invoke(params.cryptoCurrency).fold( + fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold( ifLeft = ::handleOnrampError, ifRight = { _state.update { amountStateFactory.getAmountSecondaryResetState() } }, ) @@ -184,6 +186,7 @@ internal class OnrampMainComponentModel @Inject constructor( val content = state.value as? OnrampMainComponentUM.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) @@ -216,7 +219,7 @@ internal class OnrampMainComponentModel @Inject constructor( } override fun onBuyClick(quote: OnrampProviderWithQuote.Data) { - if (isDemoCardUseCase.invoke(selectedUserWallet.cardId)) { + if (isDemoCardUseCase.invoke(userWallet.cardId)) { showDemoWarning() } else { val currentContentState = state.value as? OnrampMainComponentUM.Content ?: return diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt index eec3a23193..608587c822 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt @@ -42,16 +42,13 @@ internal fun OnrampAmountContent(state: OnrampAmountBlockUM, modifier: Modifier horizontalAlignment = Alignment.CenterHorizontally, ) { OnrampCurrencyIcon(currencyUM = state.currencyUM) - OnrampAmountField( - amountField = state.amountFieldModel, - isLoading = state.secondaryFieldModel is OnrampAmountSecondaryFieldUM.Loading, - ) + OnrampAmountField(amountField = state.amountFieldModel) OnrampAmountSecondary(state = state.secondaryFieldModel) } } @Composable -private fun OnrampAmountField(amountField: AmountFieldModel, isLoading: Boolean) { +private fun OnrampAmountField(amountField: AmountFieldModel) { val decimalFormat = rememberDecimalFormat() val requester = remember { FocusRequester() } AmountTextField( @@ -70,7 +67,7 @@ private fun OnrampAmountField(amountField: AmountFieldModel, isLoading: Boolean) color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, ), - isEnabled = !amountField.isError && !isLoading, + isEnabled = !amountField.isError, isAutoResize = true, isValuePasted = amountField.isValuePasted, onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt index cc6426d771..7d8207b969 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt @@ -14,6 +14,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.onramp.GetOnrampRedirectUrlUseCase import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.redirect.OnrampRedirectComponent import com.tangem.features.onramp.redirect.entity.OnrampRedirectTopBarUM @@ -29,6 +30,7 @@ internal class OnrampRedirectModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, private val getOnrampRedirectUrlUseCase: GetOnrampRedirectUrlUseCase, + private val getWalletsUseCase: GetWalletsUseCase, private val messageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, router: Router, @@ -36,6 +38,8 @@ internal class OnrampRedirectModel @Inject constructor( ) : Model() { private val params: OnrampRedirectComponent.Params = paramsContainer.require() + private val selectedUserWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + val state = OnrampRedirectUM( topBarConfig = OnrampRedirectTopBarUM( title = combinedReference( @@ -62,7 +66,7 @@ internal class OnrampRedirectModel @Inject constructor( fun getRedirectUrl(isDarkTheme: Boolean) { modelScope.launch { getOnrampRedirectUrlUseCase.invoke( - userWalletId = params.userWalletId, + userWallet = selectedUserWallet, quote = params.onrampProviderWithQuote, cryptoCurrency = params.cryptoCurrency, isDarkTheme = isDarkTheme, 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 48804d46be..e08b4a4477 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 @@ -61,8 +61,9 @@ internal class DefaultOnrampComponent @AssistedInject constructor( OnrampChild.Settings -> settingsComponentFactory.create( context = childByContext(componentContext), params = OnrampSettingsComponent.Params( - params.cryptoCurrency, - navigation::pop, + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + onBack = navigation::pop, ), ) OnrampChild.Main -> onrampMainComponentFactory.create( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/SelectCountryComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/SelectCountryComponent.kt index 8abf849546..869dc95993 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/SelectCountryComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/SelectCountryComponent.kt @@ -3,10 +3,15 @@ package com.tangem.features.onramp.selectcountry import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId internal interface SelectCountryComponent : ComposableBottomSheetComponent { - data class Params(val cryptoCurrency: CryptoCurrency, val onDismiss: (Boolean) -> Unit) + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val onDismiss: (Boolean) -> Unit, + ) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt index 8d0835efff..a30217c087 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt @@ -13,6 +13,7 @@ import com.tangem.domain.onramp.GetOnrampCountryUseCase import com.tangem.domain.onramp.OnrampSaveDefaultCountryUseCase import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.OnrampCountry +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.selectcountry.SelectCountryComponent import com.tangem.features.onramp.selectcountry.entity.CountryItemState @@ -44,11 +45,15 @@ internal class OnrampSelectCountryModel @Inject constructor( private val saveDefaultCountryUseCase: OnrampSaveDefaultCountryUseCase, private val getOnrampCountryUseCase: GetOnrampCountryUseCase, private val fetchOnrampCountriesUseCase: FetchOnrampCountriesUseCase, + getWalletsUseCase: GetWalletsUseCase, paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = controller.state private val params: SelectCountryComponent.Params = paramsContainer.require() + private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + + val state: StateFlow get() = controller.state + private val controller = CountryListUMController( searchBarUM = createSearchBarUM(), loadingItems = loadingItems, @@ -103,7 +108,7 @@ internal class OnrampSelectCountryModel @Inject constructor( private fun updateCountriesList() { modelScope.launch { - fetchOnrampCountriesUseCase().onLeft { + fetchOnrampCountriesUseCase(userWallet).onLeft { controller.update(UpdateCountryItemsErrorTransformer(onRetry = ::onRetry)) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/SelectCurrencyComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/SelectCurrencyComponent.kt index f8283864dd..e06fdec8ac 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/SelectCurrencyComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/SelectCurrencyComponent.kt @@ -3,10 +3,15 @@ package com.tangem.features.onramp.selectcurrency import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet internal interface SelectCurrencyComponent : ComposableBottomSheetComponent { - data class Params(val cryptoCurrency: CryptoCurrency, val onDismiss: () -> Unit) + 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/selectcurrency/model/OnrampSelectCurrencyModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt index 1162910ed0..c7899de0de 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt @@ -97,7 +97,7 @@ internal class OnrampSelectCurrencyModel @Inject constructor( private fun updateCurrenciesList() { modelScope.launch { - fetchOnrampCurrenciesUseCase().onLeft { + fetchOnrampCurrenciesUseCase(userWallet = params.userWallet).onLeft { controller.update(UpdateCurrencyItemsErrorTransformer(onRetry = ::onRetry)) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/DefaultOnrampSettingsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/DefaultOnrampSettingsComponent.kt index 941d543d82..2d61aa6571 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/DefaultOnrampSettingsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/DefaultOnrampSettingsComponent.kt @@ -52,7 +52,8 @@ internal class DefaultOnrampSettingsComponent @AssistedInject constructor( OnrampSettingsConfig.SelectCountry -> selectCountryComponentFactory.create( context = childByContext(componentContext), params = SelectCountryComponent.Params( - params.cryptoCurrency, + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, onDismiss = { model.bottomSheetNavigation.dismiss() }, ), ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/OnrampSettingsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/OnrampSettingsComponent.kt index 4b3b8d1455..c6fca3fb80 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/OnrampSettingsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/OnrampSettingsComponent.kt @@ -3,10 +3,15 @@ package com.tangem.features.onramp.settings import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId internal interface OnrampSettingsComponent : ComposableContentComponent { - data class Params(val cryptoCurrency: CryptoCurrency, val onBack: () -> Unit) + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val onBack: () -> Unit, + ) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/entity/conterter/SetOnrampSuccessContentConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/entity/conterter/SetOnrampSuccessContentConverter.kt index 834e4fb850..424213fc4b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/entity/conterter/SetOnrampSuccessContentConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/entity/conterter/SetOnrampSuccessContentConverter.kt @@ -148,6 +148,10 @@ internal class SetOnrampSuccessContentConverter( this == OnrampStatus.Status.Paid -> { resourceReference(R.string.express_status_buying_active, wrappedList(cryptoCurrency.name)) } + this == OnrampStatus.Status.RefundInProgress || + this == OnrampStatus.Status.Refunded -> { + resourceReference(R.string.express_exchange_status_failed) + } else -> { resourceReference(R.string.express_status_bought, wrappedList(cryptoCurrency.name)) } @@ -166,6 +170,12 @@ internal class SetOnrampSuccessContentConverter( wrappedList(cryptoCurrency.name), ) } + this == OnrampStatus.Status.RefundInProgress -> { + resourceReference(R.string.express_exchange_status_refunding) + } + this == OnrampStatus.Status.Refunded -> { + resourceReference(R.string.express_exchange_status_refunded) + } else -> { resourceReference(R.string.express_exchange_status_sent, wrappedList(cryptoCurrency.name)) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt index 622c7055ba..fa74f49da7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt @@ -67,7 +67,7 @@ internal class OnrampSuccessComponentModel @Inject constructor( private fun loadData() { modelScope.launch { - getOnrampTransactionUseCase(externalTxId = params.externalTxId) + getOnrampTransactionUseCase(txId = params.txId) .fold( ifLeft = { error -> Timber.e(error.toString()) @@ -97,7 +97,7 @@ internal class OnrampSuccessComponentModel @Inject constructor( return } - getOnrampStatusUseCase(txId = transaction.txId) + getOnrampStatusUseCase(userWallet = userWallet, txId = transaction.txId) .fold( ifLeft = { error -> analyticsEventHandler.sendOnrampErrorEvent( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index 09f41bc29d..ca5940bfa9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -19,6 +19,7 @@ import com.tangem.domain.tokens.GetTokenListUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.domain.GetAvailablePairsUseCase import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast @@ -52,11 +53,13 @@ internal class AvailableSwapPairsModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getAvailablePairsUseCase: GetAvailablePairsUseCase, + private val getWalletsUseCase: GetWalletsUseCase, ) : Model() { val state: StateFlow = tokenListUMController.state private var params: AvailableSwapPairsComponent.Params = paramsContainer.require() + private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val tokenListFlow = getTokenListUseCaseFlow() @@ -212,6 +215,7 @@ internal class AvailableSwapPairsModel @Inject constructor( availablePairsByNetworkFlow.update(networkInfo = networkInfo, state = lceLoading()) getAvailablePairsUseCase( + userWallet = userWallet, initialCurrency = networkInfo, currencies = statuses.map(CryptoCurrencyStatus::currency), ) diff --git a/features/send-v2/api/build.gradle.kts b/features/send-v2/api/build.gradle.kts index 7bf806b77c..fce40fb61c 100644 --- a/features/send-v2/api/build.gradle.kts +++ b/features/send-v2/api/build.gradle.kts @@ -16,4 +16,5 @@ dependencies { /** Domain models */ implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.nft.models) } \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt new file mode 100644 index 0000000000..2403e179e6 --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt @@ -0,0 +1,17 @@ +package com.tangem.features.send.v2.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId + +interface NFTSendComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val nftCollectionName: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index 6858492736..9629c4825b 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { /** Api */ implementation(projects.features.sendV2.api) implementation(projects.features.txhistory.api) + implementation(projects.features.nft.api) /** Libs */ implementation(projects.libs.crypto) @@ -54,6 +55,7 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.balanceHiding) + implementation(projects.domain.nft.models) /** Compose libraries */ implementation(deps.compose.foundation) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/SendRoute.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/CommonSendRoute.kt similarity index 58% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/SendRoute.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/CommonSendRoute.kt index 078366292e..7493c4d073 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/SendRoute.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/CommonSendRoute.kt @@ -1,35 +1,34 @@ -package com.tangem.features.send.v2.send +package com.tangem.features.send.v2.common import com.tangem.core.decompose.navigation.Route import kotlinx.serialization.Serializable -@Serializable -internal sealed class SendRoute : Route { +internal sealed class CommonSendRoute : Route { abstract val isEditMode: Boolean @Serializable - data object Empty : SendRoute() { + data object Empty : CommonSendRoute() { override val isEditMode = false } @Serializable - data object Confirm : SendRoute() { + data object Confirm : CommonSendRoute() { override val isEditMode: Boolean = false } @Serializable data class Destination( override val isEditMode: Boolean, - ) : SendRoute() + ) : CommonSendRoute() @Serializable data class Amount( override val isEditMode: Boolean, - ) : SendRoute() + ) : CommonSendRoute() @Serializable - data class Fee( - override val isEditMode: Boolean = true, - ) : SendRoute() + data object Fee : CommonSendRoute() { + override val isEditMode: Boolean = true + } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt new file mode 100644 index 0000000000..adbfd84dfc --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt @@ -0,0 +1,24 @@ +package com.tangem.features.send.v2.common + +sealed class PredefinedValues { + data object Empty : PredefinedValues() + + sealed class Content : PredefinedValues() { + abstract val amount: String + abstract val address: String + abstract val memo: String? + + data class Deeplink( + override val amount: String, + override val address: String, + override val memo: String?, + val transactionId: String, + ) : Content() + + data class QrCode( + override val amount: String, + override val address: String, + override val memo: String?, + ) : Content() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt new file mode 100644 index 0000000000..69254405cf --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt @@ -0,0 +1,87 @@ +package com.tangem.features.send.v2.common + +import com.tangem.domain.tokens.FetchPendingTransactionsUseCase +import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter +import com.tangem.utils.coroutines.DelayedWork +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.* + +@Suppress("LongParameterList") +internal class SendBalanceUpdater @AssistedInject constructor( + private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, + private val updateDelayedNetworkStatusUseCase: UpdateDelayedNetworkStatusUseCase, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, + private val txHistoryFeatureToggles: TxHistoryFeatureToggles, + private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, + @DelayedWork private val coroutineScope: CoroutineScope, + @Assisted private val userWallet: UserWallet, + @Assisted private val cryptoCurrency: CryptoCurrency, +) { + fun scheduleUpdates() { + coroutineScope.launch { + listOf( + // we should update network to find pending tx after 1 sec + async { + fetchPendingTransactionsUseCase( + userWalletId = userWallet.walletId, + networks = setOf(cryptoCurrency.network), + ) + }, + // we should update tx history and network for new balances + async { + updateTxHistory() + }, + async { + updateNetworkStatuses() + }, + ).awaitAll() + } + } + + private suspend fun updateNetworkStatuses(delay: Long = BALANCE_UPDATE_DELAY) { + updateDelayedNetworkStatusUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + delayMillis = delay, + refresh = true, + ) + } + + private suspend fun updateTxHistory() { + delay(BALANCE_UPDATE_DELAY) + val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + ) + + txHistoryItemsCountEither.onRight { + if (txHistoryFeatureToggles.isFeatureEnabled) { + txHistoryContentUpdateEmitter.triggerUpdate() + } else { + getTxHistoryItemsUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + refresh = true, + ) + } + } + } + + private companion object { + const val BALANCE_UPDATE_DELAY = 11_000L + } + + @AssistedFactory + interface Factory { + fun create(cryptoCurrency: CryptoCurrency, userWallet: UserWallet): SendBalanceUpdater + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt index 1c2c1800c4..0ce1403ae8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt @@ -1,5 +1,7 @@ package com.tangem.features.send.v2.common +import com.tangem.features.send.v2.common.ui.state.NavigationUM + internal interface SendNavigationModelCallback { fun onNavigationResult(navigationUM: NavigationUM) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt similarity index 81% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt index 8451e8d50d..83ce304653 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.v2.send.ui +package com.tangem.features.send.v2.common.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -13,12 +13,14 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.v2.send.SendRoute -import com.tangem.features.send.v2.common.NavigationUM -import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.state.NavigationUM @Composable -internal fun SendContent(state: SendUM, stackState: ChildStack) { +internal fun SendContent( + navigationUM: NavigationUM, + stackState: ChildStack, +) { Column( modifier = Modifier.Companion .background(color = TangemTheme.colors.background.tertiary) @@ -27,7 +29,7 @@ internal fun SendContent(state: SendUM, stackState: ChildStack() + private val stackNavigation = StackNavigation() - private val innerRouter = InnerRouter( + private val innerRouter = InnerRouter( stackNavigation = stackNavigation, popCallback = { onChildBack() }, ) private val initialRoute = if (params.amount == null) { - SendRoute.Destination(isEditMode = false) + CommonSendRoute.Destination(isEditMode = false) } else { - SendRoute.Empty + CommonSendRoute.Empty } private val currentRoute = MutableStateFlow(initialRoute) @@ -112,47 +115,74 @@ internal class DefaultSendComponent @AssistedInject constructor( BackHandler(onBack = ::onChildBack) SendContent( - state = state, + navigationUM = state.navigationUM, stackState = stackState, ) } - private fun createChild(route: SendRoute, factoryContext: AppComponentContext) = when (route) { - SendRoute.Empty -> getStubComponent() - is SendRoute.Destination -> getDestinationComponent(factoryContext, route) - is SendRoute.Amount -> getAmountComponent(factoryContext, route) - is SendRoute.Fee -> getFeeComponent(factoryContext) - SendRoute.Confirm -> getConfirmComponent(factoryContext) + private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { + CommonSendRoute.Empty -> getStubComponent() + is CommonSendRoute.Destination -> getDestinationComponent(factoryContext, route) + is CommonSendRoute.Amount -> getAmountComponent(factoryContext, route) + is CommonSendRoute.Fee -> getFeeComponent(factoryContext) + CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) } - private fun getDestinationComponent(factoryContext: AppComponentContext, route: SendRoute) = + private fun getDestinationComponent(factoryContext: AppComponentContext, route: CommonSendRoute) = SendDestinationComponent( appComponentContext = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, - currentRoute = currentRoute.filterIsInstance(), + currentRoute = currentRoute.filterIsInstance(), isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, userWalletId = params.userWalletId, cryptoCurrency = params.currency, callback = model, - isEditMode = route.isEditMode, + onBackClick = ::onChildBack, + onNextClick = { + if (route.isEditMode) { + onChildBack() + } else { + innerRouter.push(CommonSendRoute.Amount(isEditMode = false)) + } + }, ), ) - private fun getAmountComponent(factoryContext: AppComponentContext, route: SendRoute) = SendAmountComponent( + private fun getAmountComponent(factoryContext: AppComponentContext, route: CommonSendRoute) = SendAmountComponent( appComponentContext = factoryContext, params = SendAmountComponentParams.AmountParams( state = model.uiState.value.amountUM, - currentRoute = currentRoute.filterIsInstance(), + currentRoute = currentRoute.filterIsInstance(), isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, userWallet = model.userWallet, appCurrency = model.appCurrency, cryptoCurrencyStatus = model.cryptoCurrencyStatus, callback = model, - isEditMode = route.isEditMode, - predefinedAmountValue = model.predefinedAmountValue, + predefinedValues = model.predefinedValues, + onBackClick = { + if (route.isEditMode) { + onChildBack() + } else { + analyticsEventHandler.send( + SendAnalyticEvents.CloseButtonClicked( + source = SendScreenSource.Amount, + isFromSummary = false, + isValid = model.uiState.value.amountUM.isPrimaryButtonEnabled, + ), + ) + router.pop() + } + }, + onNextClick = { + if (route.isEditMode) { + onChildBack() + } else { + innerRouter.push(CommonSendRoute.Confirm) + } + }, ), ) @@ -165,15 +195,17 @@ internal class DefaultSendComponent @AssistedInject constructor( appComponentContext = factoryContext, params = SendFeeComponentParams.FeeParams( state = model.uiState.value.feeUM, - currentRoute = currentRoute.filterIsInstance(), + currentRoute = currentRoute.filterIsInstance(), analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, userWallet = model.userWallet, cryptoCurrencyStatus = model.cryptoCurrencyStatus, feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, appCurrency = model.appCurrency, + onLoadFee = model::loadFee, sendAmount = sendAmount, destinationAddress = destinationAddress, callback = model, + onNextClick = ::onChildBack, ), ) } else { @@ -187,14 +219,14 @@ internal class DefaultSendComponent @AssistedInject constructor( val predefinedAddress = params.destinationAddress val predefinedValues = if (predefinedAmount != null && predefinedTxId != null && predefinedAddress != null) { - SendConfirmComponent.Params.PredefinedValues.Content( + PredefinedValues.Content.Deeplink( amount = predefinedAmount, address = predefinedAddress, - tag = params.tag, + memo = params.tag, transactionId = predefinedTxId, ) } else { - SendConfirmComponent.Params.PredefinedValues.Empty + PredefinedValues.Empty } return SendConfirmComponent( appComponentContext = factoryContext, @@ -209,6 +241,7 @@ internal class DefaultSendComponent @AssistedInject constructor( appCurrency = model.appCurrency, callback = model, predefinedValues = predefinedValues, + onLoadFee = model::loadFee, ), ) } @@ -216,7 +249,7 @@ internal class DefaultSendComponent @AssistedInject constructor( private fun getStubComponent() = ComposableContentComponent { } private fun onChildBack() { - val isEmptyRoute = childStack.value.active.configuration == SendRoute.Empty + val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index 943e48d31f..a7145d5d6e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -4,25 +4,30 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import arrow.core.Either +import com.tangem.blockchain.common.transaction.TransactionFee 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.decompose.ComposableContentComponent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.wallets.models.UserWallet -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel import com.tangem.features.send.v2.send.confirm.ui.SendConfirmContent -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent -import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData import com.tangem.utils.extensions.orZero import kotlinx.coroutines.flow.* @@ -38,15 +43,13 @@ internal class SendConfirmComponent( private val destinationBlockComponent = SendDestinationBlockComponent( appComponentContext = child("sendConfirmDestinationBlock"), - params = SendDestinationComponentParams.DestinationBlockParams( + params = DestinationBlockParams( state = model.uiState.value.destinationUM, analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrency = params.cryptoCurrencyStatus.currency, blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), - isPredefinedValues = params.predefinedValues is Params.PredefinedValues.Content, - predefinedAddressValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.address, - predefinedMemoValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.tag, + predefinedValues = params.predefinedValues, ), onResult = model::onDestinationResult, onClick = model::showEditDestination, @@ -61,8 +64,7 @@ internal class SendConfirmComponent( cryptoCurrencyStatus = params.cryptoCurrencyStatus, appCurrency = params.appCurrency, blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), - isPredefinedValues = params.predefinedValues is Params.PredefinedValues.Content, - predefinedAmountValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.amount, + predefinedValues = params.predefinedValues, ), onResult = model::onAmountResult, onClick = model::showEditAmount, @@ -77,9 +79,10 @@ internal class SendConfirmComponent( cryptoCurrencyStatus = params.cryptoCurrencyStatus, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, - sendAmount = model.enteredAmount.orZero(), - destinationAddress = model.enteredDestination.orEmpty(), + sendAmount = model.confirmData.enteredAmount.orZero(), + destinationAddress = model.confirmData.enteredDestination.orEmpty(), blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + onLoadFee = params.onLoadFee, ), onResult = model::onFeeResult, onClick = model::showEditFee, @@ -93,13 +96,15 @@ internal class SendConfirmComponent( cryptoCurrencyStatus = params.cryptoCurrencyStatus, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, - destinationAddress = model.enteredDestination.orEmpty(), - memo = model.enteredMemo, - amountValue = model.enteredAmount.orZero(), - reduceAmountBy = model.reduceAmountBy.orZero(), - isIgnoreReduce = model.isIgnoreReduce, - fee = model.fee, - feeError = model.feeError, + notificationData = NotificationData( + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + memo = model.confirmData.enteredMemo, + amountValue = model.confirmData.enteredAmount.orZero(), + reduceAmountBy = model.confirmData.reduceAmountBy.orZero(), + isIgnoreReduce = model.confirmData.isIgnoreReduce, + fee = model.confirmData.fee, + feeError = model.confirmData.feeError, + ), ), ) @@ -140,20 +145,11 @@ internal class SendConfirmComponent( val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val appCurrency: AppCurrency, val callback: ModelCallback, - val currentRoute: Flow, + val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val predefinedValues: PredefinedValues, - ) { - sealed class PredefinedValues { - data object Empty : PredefinedValues() - data class Content( - val transactionId: String, - val amount: String, - val address: String, - val tag: String?, - ) : PredefinedValues() - } - } + val onLoadFee: suspend () -> Either, + ) interface ModelCallback { fun onResult(sendUM: SendUM) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt new file mode 100644 index 0000000000..e6aa732445 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.send.confirm.model + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.transaction.error.GetFeeError +import java.math.BigDecimal + +data class ConfirmData( + val enteredAmount: BigDecimal?, + val reduceAmountBy: BigDecimal, + val isIgnoreReduce: Boolean, + val enteredDestination: String?, + val enteredMemo: String?, + val fee: Fee?, + val feeError: GetFeeError?, +) \ No newline at end of file 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 daf5df6be6..8b0a2cad19 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 @@ -5,7 +5,6 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -25,20 +24,17 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase -import com.tangem.domain.tokens.FetchPendingTransactionsUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase -import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase 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.utils.convertToSdkAmount -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.SendBalanceUpdater +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.SendRoute import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.send.analytics.SendAnalyticHelper @@ -47,7 +43,6 @@ import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmIn import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSendingStateTransformer import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSentStateTransformer import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformer -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM @@ -58,17 +53,13 @@ import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData -import com.tangem.features.txhistory.TxHistoryFeatureToggles -import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.DelayedWork import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.stripZeroPlainString import com.tangem.utils.transformer.update -import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import timber.log.Timber -import java.math.BigDecimal import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -89,21 +80,15 @@ internal class SendConfirmModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, - private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, - private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, - private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, - private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, - private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, private val notificationsUpdateTrigger: NotificationsUpdateTrigger, private val alertFactory: SendConfirmAlertFactory, private val sendAnalyticHelper: SendAnalyticHelper, - private val txHistoryFeatureToggles: TxHistoryFeatureToggles, - @DelayedWork private val coroutineScope: CoroutineScope, private val urlOpener: UrlOpener, private val shareManager: ShareManager, + sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents { private val params: SendConfirmComponent.Params = paramsContainer.require() @@ -113,6 +98,8 @@ internal class SendConfirmModel @Inject constructor( private val cryptoCurrencyStatus = params.cryptoCurrencyStatus private val cryptoCurrency = cryptoCurrencyStatus.currency + private val sendBalanceUpdater = sendBalanceUpdaterFactory.create(cryptoCurrency, userWallet) + private val _uiState = MutableStateFlow(params.state) val uiState = _uiState.asStateFlow() @@ -125,20 +112,16 @@ internal class SendConfirmModel @Inject constructor( private val feeSelectorUM get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content - val enteredAmount: BigDecimal? - get() = amountState?.amountTextField?.cryptoAmount?.value - val reduceAmountBy: BigDecimal - get() = amountState?.reduceAmountBy.orZero() - val isIgnoreReduce: Boolean - get() = amountState?.isIgnoreReduce == true - val enteredDestination: String? - get() = destinationUM?.addressTextField?.value - val enteredMemo: String? - get() = destinationUM?.memoTextField?.value - val fee: Fee? - get() = feeSelectorUM?.selectedFee - val feeError: GetFeeError? - get() = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error + val confirmData: ConfirmData + get() = ConfirmData( + enteredAmount = amountState?.amountTextField?.cryptoAmount?.value, + enteredMemo = destinationUM?.memoTextField?.value, + reduceAmountBy = amountState?.reduceAmountBy.orZero(), + isIgnoreReduce = amountState?.isIgnoreReduce == true, + enteredDestination = destinationUM?.addressTextField?.value, + fee = feeSelectorUM?.selectedFee, + feeError = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error, + ) private var sendIdleTimer: Long = 0L private var isAmountSubtractAvailable = false @@ -183,7 +166,7 @@ internal class SendConfirmModel @Inject constructor( it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) } analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) - router.push(SendRoute.Destination(isEditMode = true)) + router.push(CommonSendRoute.Destination(isEditMode = true)) } } @@ -195,7 +178,7 @@ internal class SendConfirmModel @Inject constructor( it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) } analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount)) - router.push(SendRoute.Amount(isEditMode = true)) + router.push(CommonSendRoute.Amount(isEditMode = true)) } } @@ -207,7 +190,7 @@ internal class SendConfirmModel @Inject constructor( it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) } analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee)) - router.push(SendRoute.Fee()) + router.push(CommonSendRoute.Fee) } } @@ -236,15 +219,15 @@ internal class SendConfirmModel @Inject constructor( override fun onFailedTxEmailClick(errorMessage: String) { val amountValue = amountState?.amountTextField?.cryptoAmount?.value - val feeValue = fee?.amount?.value + val feeValue = confirmData.fee?.amount?.value val receivingAmount = if (amountValue != null && feeValue != null) { checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isAmountSubtractAvailable, cryptoCurrencyStatus = cryptoCurrencyStatus, - amountValue = enteredAmount.orZero(), + amountValue = confirmData.enteredAmount.orZero(), feeValue = feeValue, - reduceAmountBy = reduceAmountBy, + reduceAmountBy = confirmData.reduceAmountBy, ) } else { null @@ -257,7 +240,7 @@ internal class SendConfirmModel @Inject constructor( errorMessage = errorMessage, blockchainId = cryptoCurrency.network.id.value, derivationPath = cryptoCurrency.network.derivationPath.value, - destinationAddress = enteredDestination.orEmpty(), + destinationAddress = confirmData.enteredDestination.orEmpty(), tokenSymbol = if (amount?.type is AmountType.Token) { amount.currencySymbol } else { @@ -322,7 +305,7 @@ internal class SendConfirmModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, amountValue = amountValue, feeValue = feeValue, - reduceAmountBy = reduceAmountBy.orZero(), + reduceAmountBy = confirmData.reduceAmountBy.orZero(), ) modelScope.launch { @@ -369,7 +352,7 @@ internal class SendConfirmModel @Inject constructor( ifRight = { updateTransactionStatus(txData) addTokenToWalletIfNeeded() - scheduleUpdates() + sendBalanceUpdater.scheduleUpdates() sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value) }, ) @@ -380,7 +363,7 @@ internal class SendConfirmModel @Inject constructor( val wallets = destinationUM?.wallets ?: return val receivingUserWallet = wallets - .firstOrNull { it.address == enteredDestination } + .firstOrNull { it.address == confirmData.enteredDestination } ?: return val userWalletId = receivingUserWallet.userWalletId ?: return @@ -403,49 +386,6 @@ internal class SendConfirmModel @Inject constructor( _uiState.update(SendConfirmSentStateTransformer(txData, txUrl)) } - private fun scheduleUpdates() { - coroutineScope.launch { - listOf( - // we should update network to find pending tx after 1 sec - async { - fetchPendingTransactionsUseCase(userWallet.walletId, setOf(cryptoCurrency.network)) - }, - // we should update tx history and network for new balance - async { - updateTxHistory() - }, - async { - updateDelayedCurrencyStatusUseCase( - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - delayMillis = BALANCE_UPDATE_DELAY, - refresh = true, - ) - }, - ).awaitAll() - } - } - - private suspend fun updateTxHistory() { - delay(BALANCE_UPDATE_DELAY) - val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - ) - - txHistoryItemsCountEither.onRight { - if (txHistoryFeatureToggles.isFeatureEnabled) { - txHistoryContentUpdateEmitter.triggerUpdate() - } else { - getTxHistoryItemsUseCase( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - refresh = true, - ) - } - } - } - private fun subscribeOnCheckFeeResultUpdates() { sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> if (isFeeResultSuccess) { @@ -462,13 +402,13 @@ internal class SendConfirmModel @Inject constructor( modelScope.launch { notificationsUpdateTrigger.triggerUpdate( data = NotificationData( - destinationAddress = enteredDestination.orEmpty(), - memo = enteredMemo, - amountValue = enteredAmount.orZero(), - reduceAmountBy = reduceAmountBy.orZero(), - isIgnoreReduce = isIgnoreReduce, - fee = fee, - feeError = feeError, + destinationAddress = confirmData.enteredDestination.orEmpty(), + memo = confirmData.enteredMemo, + amountValue = confirmData.enteredAmount.orZero(), + reduceAmountBy = confirmData.reduceAmountBy.orZero(), + isIgnoreReduce = confirmData.isIgnoreReduce, + fee = confirmData.fee, + feeError = confirmData.feeError, ), ) _uiState.update { @@ -490,7 +430,7 @@ internal class SendConfirmModel @Inject constructor( flow = uiState, flow2 = params.currentRoute, transform = { state, route -> state to route }, - ).filter { it.second is SendRoute.Confirm }.onEach { (state, _) -> + ).filter { it.second is CommonSendRoute.Confirm }.onEach { (state, _) -> val amountUM = state.amountUM as? AmountState.Data val confirmUM = state.confirmUM params.callback.onResult( @@ -554,6 +494,5 @@ internal class SendConfirmModel @Inject constructor( private companion object { const val CHECK_FEE_UPDATE_DELAY = 10_000L - const val BALANCE_UPDATE_DELAY = 11_000L } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt index c165ade1ff..c980c558a7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.v2.send.confirm.model.transformers import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.persistentListOf diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt index e632f7b86f..9a8e44344a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt @@ -1,6 +1,6 @@ package com.tangem.features.send.v2.send.confirm.model.transformers -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.utils.transformer.Transformer diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt index c08c566fc2..2d1b64e6b7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt @@ -1,13 +1,13 @@ package com.tangem.features.send.v2.send.confirm.model.transformers import com.tangem.blockchain.common.TransactionData -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.utils.transformer.Transformer internal class SendConfirmSentStateTransformer( - val txData: TransactionData.Uncompiled, - val txUrl: String, + private val txData: TransactionData.Uncompiled, + private val txUrl: String, ) : Transformer { override fun transform(prevState: SendUM): SendUM { return prevState.copy( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt index 0066a9268d..853e5bcdb5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt @@ -16,7 +16,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.subcomponents.fee.model.checkIfFeeTooHigh import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt index f3a151a432..bcf7d3283a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt @@ -5,20 +5,15 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM @@ -26,12 +21,16 @@ import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.components.transactions.TransactionDoneTitle -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.tapHelp import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent @@ -39,11 +38,8 @@ import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent import com.tangem.features.send.v2.subcomponents.notifications import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.delay -private const val TAP_HELP_KEY = "TAP_HELP_KEY" private const val BLOCKS_KEY = "BLOCKS_KEY" -private const val TAP_HELP_ANIMATION_DELAY = 500L @Suppress("LongParameterList") @Composable @@ -148,45 +144,4 @@ private fun LazyListScope.blocks( feeBlockComponent.Content(modifier = Modifier) } } -} - -private fun LazyListScope.tapHelp(isDisplay: Boolean, modifier: Modifier = Modifier) { - item(key = TAP_HELP_KEY) { - var wrappedIsDisplay by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = isDisplay) { - delay(TAP_HELP_ANIMATION_DELAY) - wrappedIsDisplay = isDisplay - } - - if (wrappedIsDisplay) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .fillMaxWidth() - .animateItem() - .padding(top = TangemTheme.dimens.spacing20), - ) { - val background = TangemTheme.colors.button.secondary - Icon( - painter = painterResource(id = R.drawable.ic_send_hint_shape_12), - tint = TangemTheme.colors.button.secondary, - contentDescription = null, - modifier = Modifier, - ) - Text( - text = stringResourceSafe(id = R.string.send_summary_tap_hint), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - modifier = Modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(background) - .padding( - horizontal = TangemTheme.dimens.spacing14, - vertical = TangemTheme.dimens.spacing12, - ), - ) - } - } - } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/CommonSendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/CommonSendModelModule.kt new file mode 100644 index 0000000000..d2201019ff --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/CommonSendModelModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.send.v2.send.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel +import com.tangem.features.send.v2.send.model.SendModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface CommonSendModelModule { + + @Binds + @IntoMap + @ClassKey(SendModel::class) + fun provideSendModel(model: SendModel): Model + + @Binds + @IntoMap + @ClassKey(SendConfirmModel::class) + fun provideSendConfirmModel(model: SendConfirmModel): Model +} \ No newline at end of file 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 2e22376c66..c153cbe90e 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 @@ -3,6 +3,7 @@ package com.tangem.features.send.v2.send.model import androidx.compose.runtime.Stable import arrow.core.Either import arrow.core.getOrElse +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -26,14 +27,17 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.usecase.GetTransferFeeUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.SendComponent -import com.tangem.features.send.v2.common.NavigationUM -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.send.confirm.SendConfirmComponent import com.tangem.features.send.v2.send.confirm.model.SendConfirmAlertFactory -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateQRTrigger @@ -75,6 +79,7 @@ internal class SendModel @Inject constructor( private val getCardInfoUseCase: GetCardInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getTransferFeeUseCase: GetTransferFeeUseCase, private val sendAmountUpdateQRTrigger: SendAmountUpdateQRTrigger, ) : Model(), SendComponentCallback { @@ -92,7 +97,7 @@ internal class SendModel @Inject constructor( var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() var appCurrency: AppCurrency = AppCurrency.Default - var predefinedAmountValue: String? = null + var predefinedValues: PredefinedValues = PredefinedValues.Empty private var balanceHidingJobHolder = JobHolder() @@ -112,7 +117,7 @@ internal class SendModel @Inject constructor( } override fun onAmountResult(amountUM: AmountState) { - predefinedAmountValue = null // reset predefined amount + resetPredefinedAmount() _uiState.update { it.copy(amountUM = amountUM) } } @@ -121,10 +126,34 @@ internal class SendModel @Inject constructor( } override fun onResult(sendUM: SendUM) { - predefinedAmountValue = null // reset predefined amount + resetPredefinedAmount() _uiState.update { sendUM } } + suspend fun loadFee(): Either { + val destinationUM = uiState.value.destinationUM as? DestinationUM.Content ?: error("Invalid destination") + val amountUM = uiState.value.amountUM as? AmountState.Data ?: error("Invalid amount") + val enteredDestinationAddress = destinationUM.addressTextField.value + val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount") + + return getTransferFeeUseCase( + destination = enteredDestinationAddress, + amount = enteredAmount, + userWallet = userWallet, + cryptoCurrency = params.currency, + ) + } + + private fun resetPredefinedAmount() { + // reset predefined amount + val internalPredefinedValues = predefinedValues + predefinedValues = when (internalPredefinedValues) { + is PredefinedValues.Content.Deeplink -> internalPredefinedValues.copy(amount = "") + is PredefinedValues.Content.QrCode -> internalPredefinedValues.copy(amount = "") + PredefinedValues.Empty -> internalPredefinedValues + } + } + private fun initAppCurrency() { modelScope.launch { appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } @@ -218,7 +247,7 @@ internal class SendModel @Inject constructor( feeCryptoCurrencyStatus = feeCurrencyStatus if (params.amount != null) { - router.replaceAll(SendRoute.Confirm) + router.replaceAll(CommonSendRoute.Confirm) } } @@ -233,10 +262,15 @@ internal class SendModel @Inject constructor( val parsedQrCode = parseQrCodeUseCase(address, cryptoCurrency).getOrNull() // Decompose component can be active or inactive depending on its state and navigation stack // If it is in inactive state use parameter to pass value to amount component - predefinedAmountValue = parsedQrCode?.amount?.parseBigDecimal(cryptoCurrency.decimals) + val amount = parsedQrCode?.amount?.parseBigDecimal(cryptoCurrency.decimals) + predefinedValues = PredefinedValues.Content.QrCode( + amount = amount.orEmpty(), + address = parsedQrCode?.address.orEmpty(), + memo = parsedQrCode?.memo, + ) // If it is in active state use flow to update value in amount component modelScope.launch { - predefinedAmountValue?.let { sendAmountUpdateQRTrigger.triggerUpdateAmount(it) } + amount?.let { sendAmountUpdateQRTrigger.triggerUpdateAmount(it) } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt index c36bda9430..8359df5247 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt @@ -1,8 +1,8 @@ package com.tangem.features.send.v2.send.ui.state import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.features.send.v2.common.NavigationUM -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt new file mode 100644 index 0000000000..4b65d6d5ae --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt @@ -0,0 +1,202 @@ +package com.tangem.features.send.v2.sendnft + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.value.ObserveLifecycleMode +import com.arkivanov.decompose.value.subscribe +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.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.send.v2.api.NFTSendComponent +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.SendContent +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent +import com.tangem.features.send.v2.sendnft.model.NFTSendModel +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import java.math.BigDecimal + +internal class DefaultNFTSendComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: NFTSendComponent.Params, + private val nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, +) : NFTSendComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val initialRoute = CommonSendRoute.Empty + private val currentRouteFlow = MutableStateFlow(initialRoute) + + private val model: NFTSendModel = getOrCreateModel(params = params, router = innerRouter) + + private val childStack = childStack( + key = "NFTSendInnerStack", + source = stackNavigation, + serializer = null, + initialConfiguration = initialRoute, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + configuration, + childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + init { + childStack.subscribe( + lifecycle = lifecycle, + mode = ObserveLifecycleMode.CREATE_DESTROY, + ) { stack -> + componentScope.launch { + when (val activeComponent = stack.active.instance) { + is NFTSendConfirmComponent -> if (currentRouteFlow.value.isEditMode) { + // analyticsEventHandler.send(SendAnalyticEvents.ConfirmationScreenOpened) + activeComponent.updateState(model.uiState.value) + } + is SendDestinationComponent -> { + // analyticsEventHandler.send(SendAnalyticEvents.AddressScreenOpened) + activeComponent.updateState(model.uiState.value.destinationUM) + } + is SendFeeComponent -> { + // analyticsEventHandler.send(SendAnalyticEvents.FeeScreenOpened) + } + } + currentRouteFlow.emit(stack.active.configuration) + } + } + } + + @Composable + override fun Content(modifier: Modifier) { + val stackState by childStack.subscribeAsState() + val state by model.uiState.collectAsStateWithLifecycle() + + BackHandler(onBack = ::onChildBack) + SendContent( + navigationUM = state.navigationUM, + stackState = stackState, + ) + } + + private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { + is CommonSendRoute.Destination -> getDestinationComponent(factoryContext, route) + is CommonSendRoute.Fee -> getFeeComponent(factoryContext) + CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) + else -> getStubComponent() + } + + private fun getDestinationComponent(factoryContext: AppComponentContext, route: CommonSendRoute) = + SendDestinationComponent( + appComponentContext = factoryContext, + params = SendDestinationComponentParams.DestinationParams( + state = model.uiState.value.destinationUM, + currentRoute = currentRouteFlow.filterIsInstance(), + isBalanceHidingFlow = model.isBalanceHiddenFlow, + analyticsCategoryName = "", // todo + userWalletId = params.userWalletId, + cryptoCurrency = model.cryptoCurrency, + callback = model, + onBackClick = ::onChildBack, + onNextClick = { + if (route.isEditMode) { + onChildBack() + } else { + innerRouter.push(CommonSendRoute.Confirm) + } + }, + ), + ) + + private fun getFeeComponent(factoryContext: AppComponentContext): ComposableContentComponent { + val state = model.uiState.value + val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value + return if (destinationAddress != null) { + SendFeeComponent( + appComponentContext = factoryContext, + params = SendFeeComponentParams.FeeParams( + state = model.uiState.value.feeUM, + currentRoute = currentRouteFlow.filterIsInstance(), + analyticsCategoryName = "", // todo + userWallet = model.userWallet, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + sendAmount = BigDecimal.ZERO, + onLoadFee = model::loadFee, + destinationAddress = destinationAddress, + callback = model, + onNextClick = ::onChildBack, + ), + ) + } else { + getStubComponent() + } + } + + private fun getConfirmComponent(factoryContext: AppComponentContext) = NFTSendConfirmComponent( + appComponentContext = factoryContext, + nftDetailsBlockComponentFactory = nftDetailsBlockComponentFactory, + params = NFTSendConfirmComponent.Params( + state = model.uiState.value, + analyticsCategoryName = "", // todo + userWallet = model.userWallet, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + callback = model, + currentRoute = currentRouteFlow.filterIsInstance(), + isBalanceHidingFlow = model.isBalanceHiddenFlow, + onLoadFee = model::loadFee, + ), + ) + + private fun getStubComponent() = ComposableContentComponent { } + + private fun onChildBack() { + val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty + val isEmptyStack = childStack.value.backStack.isEmpty() + val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success + + if (isEmptyRoute || isEmptyStack || isSuccess) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : NFTSendComponent.Factory { + override fun create(context: AppComponentContext, params: NFTSendComponent.Params): DefaultNFTSendComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt new file mode 100644 index 0000000000..486dd72606 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt @@ -0,0 +1,152 @@ +package com.tangem.features.send.v2.sendnft.confirm + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import arrow.core.Either +import com.tangem.blockchain.common.transaction.TransactionFee +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.decompose.ComposableContentComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.model.NFTSendConfirmModel +import com.tangem.features.send.v2.sendnft.confirm.ui.NFTSendConfirmContent +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams +import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import kotlinx.coroutines.flow.* +import java.math.BigDecimal + +internal class NFTSendConfirmComponent( + appComponentContext: AppComponentContext, + params: Params, + nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: NFTSendConfirmModel = getOrCreateModel(params = params) + + private val blockClickEnableFlow = MutableStateFlow(false) + + private val destinationBlockComponent = + SendDestinationBlockComponent( + appComponentContext = child("NFTSendConfirmDestinationBlock"), + params = DestinationBlockParams( + state = model.uiState.value.destinationUM, + analyticsCategoryName = params.analyticsCategoryName, + userWalletId = params.userWallet.walletId, + cryptoCurrency = params.cryptoCurrencyStatus.currency, + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + predefinedValues = PredefinedValues.Empty, + ), + onResult = model::onDestinationResult, + onClick = model::showEditDestination, + ) + + private val feeBlockComponent = SendFeeBlockComponent( + appComponentContext = child("NFTSendConfirmFeeBlock"), + params = SendFeeComponentParams.FeeBlockParams( + state = model.uiState.value.feeUM, + analyticsCategoryName = params.analyticsCategoryName, + userWallet = params.userWallet, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = params.appCurrency, + sendAmount = BigDecimal.ZERO, + onLoadFee = params.onLoadFee, + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + ), + onResult = model::onFeeResult, + onClick = model::showEditFee, + ) + + private val nftDetailsBlockComponent = nftDetailsBlockComponentFactory.create( + context = child("NFTDetailsBlock"), + params = NFTDetailsBlockComponent.Params( + userWalletId = params.userWallet.walletId, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + ), + ) + + private val notificationsComponent = NotificationsComponent( + appComponentContext = child("NFTSendConfirmNotifications"), + params = NotificationsComponent.Params( + analyticsCategoryName = params.analyticsCategoryName, + userWalletId = params.userWallet.walletId, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = params.appCurrency, + notificationData = NotificationData( + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + memo = model.confirmData.enteredMemo, + amountValue = BigDecimal.ZERO, + reduceAmountBy = BigDecimal.ZERO, + isIgnoreReduce = false, + fee = model.confirmData.fee, + feeError = model.confirmData.feeError, + ), + ), + ) + + init { + model.uiState.onEach { state -> + val confirmUM = state.confirmUM as? ConfirmUM.Content + blockClickEnableFlow.value = confirmUM?.isSending == false + }.launchIn(componentScope) + } + + fun updateState(state: NFTSendUM) { + destinationBlockComponent.updateState(state.destinationUM) + feeBlockComponent.updateState(state.feeUM) + model.updateState(state) + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val notificationState by notificationsComponent.state.collectAsStateWithLifecycle() + + NFTSendConfirmContent( + nftSendUM = state, + destinationBlockComponent = destinationBlockComponent, + feeBlockComponent = feeBlockComponent, + nftDetailsBlockComponent = nftDetailsBlockComponent, + notificationsComponent = notificationsComponent, + notificationsUM = notificationState, + ) + } + + data class Params( + val state: NFTSendUM, + val analyticsCategoryName: String, + val userWallet: UserWallet, + val appCurrency: AppCurrency, + val nftAsset: NFTAsset, + val nftCollectionName: String, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val callback: ModelCallback, + val currentRoute: Flow, + val isBalanceHidingFlow: StateFlow, + val onLoadFee: suspend () -> Either, + ) + + interface ModelCallback { + fun onResult(nftSendUM: NFTSendUM) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/ConfirmData.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/ConfirmData.kt new file mode 100644 index 0000000000..704ce61b5c --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/ConfirmData.kt @@ -0,0 +1,11 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.transaction.error.GetFeeError + +data class ConfirmData( + val enteredDestination: String?, + val enteredMemo: String?, + val fee: Fee?, + val feeError: GetFeeError?, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmClickIntents.kt new file mode 100644 index 0000000000..292d62c6c0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmClickIntents.kt @@ -0,0 +1,16 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +internal interface NFTSendConfirmClickIntents { + + fun showEditDestination() + + fun showEditFee() + + fun onSendClick() + + fun onExploreClick() + + fun onShareClick() + + fun onFailedTxEmailClick(errorMessage: String) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..1adf375c6e --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -0,0 +1,337 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +import android.os.SystemClock +import arrow.core.getOrElse +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource +import com.tangem.features.send.v2.send.ui.state.ButtonsUM +import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmInitialStateTransformer +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmSendingStateTransformer +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformer +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class NFTSendConfirmModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val appRouter: AppRouter, + private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase, + private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, + private val notificationsUpdateTrigger: NotificationsUpdateTrigger, + private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, + private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, + private val urlOpener: UrlOpener, + private val shareManager: ShareManager, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model(), NFTSendConfirmClickIntents { + + private val params: NFTSendConfirmComponent.Params = paramsContainer.require() + + private val cryptoCurrencyStatus = params.cryptoCurrencyStatus + + private val _uiState = MutableStateFlow(params.state) + val uiState = _uiState.asStateFlow() + + private val destinationUM + get() = uiState.value.destinationUM as? DestinationUM.Content + private val feeUM + get() = uiState.value.feeUM as? FeeUM.Content + private val feeSelectorUM + get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content + + val confirmData: ConfirmData + get() = ConfirmData( + enteredDestination = destinationUM?.addressTextField?.value, + enteredMemo = destinationUM?.memoTextField?.value, + fee = feeSelectorUM?.selectedFee, + feeError = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error, + ) + + private var sendIdleTimer: Long = 0L + + init { + configConfirmNavigation() + subscribeOnNotificationsUpdateTrigger() + subscribeOnCheckFeeResultUpdates() + initialState() + } + + fun updateState(nftSendUM: NFTSendUM) { + _uiState.value = nftSendUM + updateConfirmNotifications() + } + + fun onFeeResult(feeUM: FeeUM) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update { it.copy(feeUM = feeUM) } + updateConfirmNotifications() + } + + fun onDestinationResult(destinationUM: DestinationUM) { + _uiState.update { it.copy(destinationUM = destinationUM) } + updateConfirmNotifications() + } + + override fun showEditDestination() { + modelScope.launch { + neverShowTapHelpUseCase() + _uiState.update { + val confirmUM = it.confirmUM as? ConfirmUM.Content + it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) + } + // analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) + router.push(CommonSendRoute.Destination(isEditMode = true)) + } + } + + override fun showEditFee() { + modelScope.launch { + neverShowTapHelpUseCase() + _uiState.update { + val confirmUM = it.confirmUM as? ConfirmUM.Content + it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) + } + // analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee)) + router.push(CommonSendRoute.Fee) + } + } + + override fun onSendClick() { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) + if (SystemClock.elapsedRealtime() - sendIdleTimer < CHECK_FEE_UPDATE_DELAY) { + verifyAndSendTransaction() + } else { + modelScope.launch { + sendFeeCheckReloadTrigger.triggerCheckUpdate() + } + } + } + + override fun onExploreClick() { + val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success ?: return + // analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked) + urlOpener.openUrl(confirmUM.txUrl) + } + + override fun onShareClick() { + val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success ?: return + // analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked) + shareManager.shareText(confirmUM.txUrl) + } + + override fun onFailedTxEmailClick(errorMessage: String) { + // TODO() + } + + private fun initialState() { + val confirmUM = uiState.value.confirmUM + val feeUM = uiState.value.feeUM + + modelScope.launch { + val isShowTapHelp = isSendTapHelpEnabledUseCase().getOrElse { false } + if (confirmUM is ConfirmUM.Empty || feeUM is FeeUM.Empty) { + _uiState.update { + it.copy( + confirmUM = NFTSendConfirmInitialStateTransformer( + isShowTapHelp = isShowTapHelp, + ).transform(uiState.value.confirmUM), + ) + } + updateConfirmNotifications() + } + } + } + + private fun subscribeOnNotificationsUpdateTrigger() { + notificationsUpdateTrigger.hasErrorFlow + .onEach { hasError -> + _uiState.update { + val feeUM = it.feeUM as? FeeUM.Content + val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content + it.copy( + confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy( + isPrimaryButtonEnabled = !hasError && feeSelectorUM != null, + ) ?: it.confirmUM, + ) + } + } + .launchIn(modelScope) + } + + private fun verifyAndSendTransaction() { + // TODO: + } + + // private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { + // val result = sendTransactionUseCase( + // txData = txData, + // userWallet = userWallet, + // network = cryptoCurrency.network, + // ) + // + // _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = false)) + // + // result.fold( + // ifLeft = { error -> + // // alertFactory.getSendTransactionErrorState( + // // error = error, + // // popBack = appRouter::pop, + // // onFailedTxEmailClick = ::onFailedTxEmailClick, + // // ) + // analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol)) + // }, + // ifRight = { + // updateTransactionStatus(txData) + // sendBalanceUpdater.scheduleUpdates() + // // sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value) + // }, + // ) + // } + + private fun subscribeOnCheckFeeResultUpdates() { + sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + if (isFeeResultSuccess) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + } + + // private suspend fun updateTransactionStatus(txData: TransactionData.Uncompiled) { + // val txUrl = getExplorerTransactionUrlUseCase( + // userWalletId = userWallet.walletId, + // network = cryptoCurrency.network, + // ).getOrElse { "" } + // _uiState.update(NFTSendConfirmSentStateTransformer(txData, txUrl)) + // } + + private fun updateConfirmNotifications() { + modelScope.launch { + notificationsUpdateTrigger.triggerUpdate( + data = NotificationData( + destinationAddress = confirmData.enteredDestination.orEmpty(), + memo = confirmData.enteredMemo, + amountValue = BigDecimal.ZERO, + reduceAmountBy = BigDecimal.ZERO, + isIgnoreReduce = false, + fee = confirmData.fee, + feeError = confirmData.feeError, + ), + ) + } + _uiState.update { + it.copy( + confirmUM = NFTSendConfirmationNotificationsTransformer( + feeUM = uiState.value.feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).transform(uiState.value.confirmUM), + ) + } + } + + private fun configConfirmNavigation() { + combine( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, _) -> + val confirmUM = state.confirmUM + params.callback.onResult( + state.copy( + navigationUM = NavigationUM.Content( + title = resourceReference( + id = R.string.send_summary_title, + formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name), + ), + subtitle = null, + backIconRes = R.drawable.ic_close_24, + backIconClick = { + analyticsEventHandler.send( + SendAnalyticEvents.CloseButtonClicked( + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = confirmUM.isPrimaryButtonEnabled, + ), + ) + appRouter.pop() + }, + primaryButton = ButtonsUM.PrimaryButtonUM( + text = when (confirmUM) { + is ConfirmUM.Success -> resourceReference(R.string.common_close) + is ConfirmUM.Content -> if (confirmUM.isSending) { + resourceReference(R.string.send_sending) + } else { + resourceReference(R.string.common_send) + } + else -> resourceReference(R.string.common_send) + }, + iconResId = R.drawable.ic_tangem_24.takeIf { confirmUM is ConfirmUM.Content }, + isEnabled = confirmUM.isPrimaryButtonEnabled, + isHapticClick = confirmUM is ConfirmUM.Content && !confirmUM.isSending, + onClick = { + when (confirmUM) { + is ConfirmUM.Success -> appRouter.pop() + is ConfirmUM.Content -> if (confirmUM.isSending) { + return@PrimaryButtonUM + } else { + onSendClick() + } + else -> return@PrimaryButtonUM + } + }, + ), + prevButton = null, + secondaryPairButtonsUM = ButtonsUM.SecondaryPairButtonsUM( + leftText = resourceReference(R.string.common_explore), + leftIconResId = R.drawable.ic_web_24, + onLeftClick = ::onExploreClick, + rightText = resourceReference(R.string.common_share), + rightIconResId = R.drawable.ic_share_24, + onRightClick = ::onShareClick, + ).takeIf { confirmUM is ConfirmUM.Success }, + ), + ), + ) + }.launchIn(modelScope) + } + + private companion object { + const val CHECK_FEE_UPDATE_DELAY = 10_000L + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmSentStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmSentStateTransformer.kt new file mode 100644 index 0000000000..5c613e80e6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmSentStateTransformer.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +import com.tangem.blockchain.common.TransactionData +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.utils.transformer.Transformer + +internal class NFTSendConfirmSentStateTransformer( + private val txData: TransactionData.Uncompiled, + private val txUrl: String, +) : Transformer { + override fun transform(prevState: NFTSendUM): NFTSendUM { + return prevState.copy( + confirmUM = ConfirmUM.Success( + transactionDate = txData.date?.timeInMillis ?: System.currentTimeMillis(), + txUrl = txUrl, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmInitialStateTransformer.kt new file mode 100644 index 0000000000..004117bd1d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmInitialStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.send.v2.sendnft.confirm.model.transformers + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal class NFTSendConfirmInitialStateTransformer( + private val isShowTapHelp: Boolean, +) : Transformer { + override fun transform(prevState: ConfirmUM): ConfirmUM { + return ConfirmUM.Content( + isSending = false, + showTapHelp = isShowTapHelp, + sendingFooter = TextReference.EMPTY, + notifications = persistentListOf(), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmSendingStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmSendingStateTransformer.kt new file mode 100644 index 0000000000..7b96c1a703 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmSendingStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.send.v2.sendnft.confirm.model.transformers + +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.utils.transformer.Transformer + +internal class NFTSendConfirmSendingStateTransformer( + val isSending: Boolean, +) : Transformer { + override fun transform(prevState: NFTSendUM): NFTSendUM { + val confirmUM = prevState.confirmUM as? ConfirmUM.Content ?: return prevState + return prevState.copy( + confirmUM = confirmUM.copy( + isPrimaryButtonEnabled = !isSending, + isSending = isSending, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt new file mode 100644 index 0000000000..af93239501 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt @@ -0,0 +1,55 @@ +package com.tangem.features.send.v2.sendnft.confirm.model.transformers + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.subcomponents.fee.model.checkIfFeeTooHigh +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class NFTSendConfirmationNotificationsTransformer( + private val feeUM: FeeUM, + private val analyticsEventHandler: AnalyticsEventHandler, + private val cryptoCurrency: CryptoCurrency, +) : Transformer { + override fun transform(prevState: ConfirmUM): ConfirmUM { + val state = prevState as? ConfirmUM.Content ?: return prevState + val feeUM = feeUM as? FeeUM.Content ?: return prevState + return state.copy( + notifications = buildList { + addTooHighNotification(feeUM.feeSelectorUM) + addTooLowNotification(feeUM) + }.toPersistentList(), + ) + } + + private fun MutableList.addTooLowNotification(feeUM: FeeUM.Content) { + val feeSelectorUM = feeUM.feeSelectorUM as? FeeSelectorUM.Content ?: return + val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return + val minimumValue = multipleFees.minimum.amount.value ?: return + val customAmount = feeSelectorUM.customValues.firstOrNull() ?: return + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + if (feeSelectorUM.selectedType == FeeType.Custom && minimumValue > customValue) { + add(NotificationUM.Warning.FeeTooLow) + analyticsEventHandler.send( + SendAnalyticEvents.NoticeTransactionDelays(cryptoCurrency.symbol), + ) + } + } + + private fun MutableList.addTooHighNotification(feeSelectorUM: FeeSelectorUM) { + if (feeSelectorUM !is FeeSelectorUM.Content) return + + val (isFeeTooHigh, diff) = checkIfFeeTooHigh(feeSelectorUM) + if (isFeeTooHigh) { + add(NotificationUM.Warning.TooHigh(diff)) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/ui/NFTSendConfirmContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/ui/NFTSendConfirmContent.kt new file mode 100644 index 0000000000..735f3ecaf0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/ui/NFTSendConfirmContent.kt @@ -0,0 +1,104 @@ +package com.tangem.features.send.v2.sendnft.confirm.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.tapHelp +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.notifications +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import kotlinx.collections.immutable.ImmutableList + +private const val BLOCKS_KEY = "BLOCKS_KEY" + +@Suppress("LongParameterList") +@Composable +internal fun NFTSendConfirmContent( + nftSendUM: NFTSendUM, + destinationBlockComponent: SendDestinationBlockComponent, + nftDetailsBlockComponent: NFTDetailsBlockComponent, + feeBlockComponent: SendFeeBlockComponent, + notificationsComponent: NotificationsComponent, + notificationsUM: ImmutableList, +) { + val confirmUM = nftSendUM.confirmUM as? ConfirmUM.Content + + Column { + LazyColumn( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) { + blocks( + nftSendUM = nftSendUM, + destinationBlockComponent = destinationBlockComponent, + nftDetailsBlockComponent = nftDetailsBlockComponent, + feeBlockComponent = feeBlockComponent, + ) + if (confirmUM != null) { + tapHelp(isDisplay = confirmUM.showTapHelp) + with(notificationsComponent) { + content( + state = notificationsUM, + isClickDisabled = confirmUM.isSending, + ) + } + notifications( + notifications = confirmUM.notifications, + isClickDisabled = confirmUM.isSending, + ) + } + } + } +} + +private fun LazyListScope.blocks( + nftSendUM: NFTSendUM, + destinationBlockComponent: SendDestinationBlockComponent, + nftDetailsBlockComponent: NFTDetailsBlockComponent, + feeBlockComponent: SendFeeBlockComponent, +) { + item(key = BLOCKS_KEY) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AnimatedVisibility( + visible = nftSendUM.confirmUM is ConfirmUM.Success, + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + ) { + val wrappedConfirmUM = remember(this) { nftSendUM.confirmUM as ConfirmUM.Success } + TransactionDoneTitle( + title = resourceReference(R.string.sent_transaction_sent_title), + subtitle = resourceReference( + R.string.send_date_format, + wrappedList( + wrappedConfirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter), + wrappedConfirmUM.transactionDate.toTimeFormat(), + ), + ), + modifier = Modifier.padding(vertical = 12.dp), + ) + } + destinationBlockComponent.Content(modifier = Modifier) + + nftDetailsBlockComponent.Content(modifier = Modifier) + + feeBlockComponent.Content(modifier = Modifier) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt new file mode 100644 index 0000000000..3252c7bc71 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.send.v2.sendnft.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.sendnft.confirm.model.NFTSendConfirmModel +import com.tangem.features.send.v2.sendnft.model.NFTSendModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface NFTSendModelModule { + + @Binds + @IntoMap + @ClassKey(NFTSendModel::class) + fun provideNFTSendModel(model: NFTSendModel): Model + + @Binds + @IntoMap + @ClassKey(NFTSendConfirmModel::class) + fun provideNFTSendConfirmModel(model: NFTSendConfirmModel): Model +} \ No newline at end of file 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 new file mode 100644 index 0000000000..65d322cf3f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -0,0 +1,173 @@ +package com.tangem.features.send.v2.sendnft.model + +import androidx.compose.runtime.Stable +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.blockchain.common.transaction.TransactionFee +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.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase +import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.send.v2.api.NFTSendComponent +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.properties.Delegates + +internal interface SendNFTComponentCallback : + SendFeeComponent.ModelCallback, + SendDestinationComponent.ModelCallback, + NFTSendConfirmComponent.ModelCallback + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class NFTSendModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, + private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, +) : Model(), SendNFTComponentCallback { + + val params: NFTSendComponent.Params = paramsContainer.require() + private val userWalletId = params.userWalletId + private val nftAsset = params.nftAsset + + private val _uiState = MutableStateFlow(initialState()) + val uiState = _uiState.asStateFlow() + + private val _isBalanceHiddenFlow = MutableStateFlow(false) + val isBalanceHiddenFlow = _isBalanceHiddenFlow.asStateFlow() + + var cryptoCurrency: CryptoCurrency by Delegates.notNull() + var userWallet: UserWallet by Delegates.notNull() + var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var appCurrency: AppCurrency = AppCurrency.Default + + init { + subscribeOnCurrencyStatusUpdates() + initAppCurrency() + } + + override fun onNavigationResult(navigationUM: NavigationUM) { + _uiState.update { it.copy(navigationUM = navigationUM) } + } + + override fun onResult(nftSendUM: NFTSendUM) { + _uiState.value = nftSendUM + } + + override fun onDestinationResult(destinationUM: DestinationUM) { + _uiState.update { it.copy(destinationUM = destinationUM) } + } + + override fun onFeeResult(feeUM: FeeUM) { + _uiState.update { it.copy(feeUM = feeUM) } + } + + suspend fun loadFee(): Either { + TODO() + // val destinationUM = uiState.value.destinationUM as? DestinationUM.Content ?: error("Invalid destination") + // val ownerAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + // ?: error("Invalid owner address") + // val enteredDestinationAddress = destinationUM.addressTextField.value + // val nftAsset = null // NFTSdkAssetConverter.convertBack(params.nftAsset).second + + // getNFTTransferFeeUseCase( + // ownerAddress = ownerAddress, + // destinationAddress = enteredDestinationAddress, + // nftAsset = nftAsset, + // userWallet = userWallet, + // cryptoCurrency = cryptoCurrency, + // ) + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + modelScope.launch { + getUserWalletUseCase(params.userWalletId).fold( + ifRight = { wallet -> + userWallet = wallet + + cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId).getOrNull() + ?.filterIsInstance() + ?.firstOrNull { it.network == nftAsset.network } + ?: return@launch + + getCurrenciesStatusUpdates( + isSingleWalletWithToken = wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + }, + ifLeft = { + // sendConfirmAlertFactory.getGenericErrorState(::onFailedTxEmailClick) + return@launch + }, + ) + } + } + + private fun getCurrenciesStatusUpdates(isSingleWalletWithToken: Boolean) { + getCurrencyStatusUpdatesUseCase( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = isSingleWalletWithToken, + ).onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifRight = { cryptoStatus -> + cryptoCurrencyStatus = cryptoStatus + feeCryptoCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoStatus, + ).getOrNull() ?: cryptoStatus + + if (uiState.value.destinationUM is DestinationUM.Empty) { + router.replaceAll(CommonSendRoute.Destination(isEditMode = false)) + } + }, + ifLeft = { + // sendConfirmAlertFactory.getGenericErrorState { + // onFailedTxEmailClick(it.toString()) + // } + }, + ) + }.launchIn(modelScope) + } + + private fun initialState(): NFTSendUM = NFTSendUM( + destinationUM = DestinationUM.Empty(), + feeUM = FeeUM.Empty(), + confirmUM = ConfirmUM.Empty, + navigationUM = NavigationUM.Empty, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt new file mode 100644 index 0000000000..526bfdd621 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.send.v2.sendnft.ui.state + +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM + +internal data class NFTSendUM( + val destinationUM: DestinationUM, + val feeUM: FeeUM, + val confirmUM: ConfirmUM, + val navigationUM: NavigationUM, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt index b616b506f7..b05e56c031 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt @@ -9,6 +9,7 @@ import com.tangem.common.ui.amountScreen.ui.AmountBlock import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountBlockParams import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel import kotlinx.coroutines.flow.launchIn @@ -39,7 +40,7 @@ internal class SendAmountBlockComponent( AmountBlock( amountState = state, isClickDisabled = !isClickEnabled, - isEditingDisabled = params.isPredefinedValues, + isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, onClick = onClick, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt index 0df4012c9d..7a59f9cfde 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt @@ -4,7 +4,8 @@ import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent.ModelCallback import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -16,7 +17,7 @@ internal sealed class SendAmountComponentParams { abstract val userWallet: UserWallet abstract val appCurrency: AppCurrency abstract val cryptoCurrencyStatus: CryptoCurrencyStatus - abstract val predefinedAmountValue: String? + abstract val predefinedValues: PredefinedValues data class AmountParams( override val state: AmountState, @@ -24,11 +25,12 @@ internal sealed class SendAmountComponentParams { override val userWallet: UserWallet, override val appCurrency: AppCurrency, override val cryptoCurrencyStatus: CryptoCurrencyStatus, - override val predefinedAmountValue: String?, - val isEditMode: Boolean, + override val predefinedValues: PredefinedValues, val callback: ModelCallback, - val currentRoute: Flow, + val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, + val onBackClick: () -> Unit, + val onNextClick: () -> Unit, ) : SendAmountComponentParams() data class AmountBlockParams( @@ -37,8 +39,7 @@ internal sealed class SendAmountComponentParams { override val userWallet: UserWallet, override val appCurrency: AppCurrency, override val cryptoCurrencyStatus: CryptoCurrencyStatus, - override val predefinedAmountValue: String?, + override val predefinedValues: PredefinedValues, val blockClickEnableFlow: StateFlow, - val isPredefinedValues: Boolean, ) : SendAmountComponentParams() } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index 3c137c1bb4..11974ef160 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -1,7 +1,6 @@ package com.tangem.features.send.v2.subcomponents.amount.model import androidx.compose.runtime.Stable -import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.amountScreen.converters.* import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer @@ -13,17 +12,14 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler 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.currency.icon.converter.CryptoCurrencyToIconStateConverter 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.tokens.GetMinimumTransactionAmountSyncUseCase -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.SendRoute -import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents -import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceListener @@ -46,8 +42,6 @@ import javax.inject.Inject internal class SendAmountModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, - private val appRouter: AppRouter, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, private val sendAmountReduceListener: SendAmountReduceListener, private val feeReloadTrigger: SendFeeReloadTrigger, @@ -109,7 +103,10 @@ internal class SendAmountModel @Inject constructor( ) } } - params.predefinedAmountValue?.let(::onAmountValueChange) + val predefinedValues = params.predefinedValues as? PredefinedValues.Content + if (predefinedValues?.amount != null) { + onAmountValueChange(predefinedValues.amount) + } } fun updateState(amountUM: AmountState) { @@ -167,11 +164,6 @@ internal class SendAmountModel @Inject constructor( ) } saveResult() - if ((params as? SendAmountComponentParams.AmountParams)?.isEditMode == true) { - router.pop() - } else { - router.push(SendRoute.Confirm) - } } private fun subscribeOnAmountReduceToTriggerUpdates() { @@ -247,20 +239,7 @@ internal class SendAmountModel @Inject constructor( } else { R.drawable.ic_close_24 }, - backIconClick = { - if (route.isEditMode) { - router.pop() - } else { - analyticsEventHandler.send( - SendAnalyticEvents.CloseButtonClicked( - source = SendScreenSource.Amount, - isFromSummary = false, - isValid = state.isPrimaryButtonEnabled, - ), - ) - appRouter.pop() - } - }, + backIconClick = { params.onBackClick() }, primaryButton = ButtonsUM.PrimaryButtonUM( text = if (route.isEditMode) { resourceReference(R.string.common_continue) @@ -268,7 +247,10 @@ internal class SendAmountModel @Inject constructor( resourceReference(R.string.common_next) }, isEnabled = state.isPrimaryButtonEnabled, - onClick = ::onAmountNext, + onClick = { + onAmountNext() + params.onNextClick() + }, ), prevButton = ButtonsUM.PrimaryButtonUM( text = TextReference.EMPTY, @@ -276,7 +258,7 @@ internal class SendAmountModel @Inject constructor( isEnabled = true, onClick = { saveResult() - router.pop() + params.onBackClick() }, ).takeIf { route.isEditMode.not() }, secondaryPairButtonsUM = null, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt index ff98000eed..5f500027fa 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel import com.tangem.features.send.v2.subcomponents.destination.ui.DestinationBlock import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM @@ -38,7 +39,7 @@ internal class SendDestinationBlockComponent( DestinationBlock( destinationUM = state, isClickDisabled = !isClickEnabled, - isEditingDisabled = params.isPredefinedValues, + isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, onClick = onClick, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt index f4a69acb24..ecd05eba6d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt @@ -2,7 +2,8 @@ package com.tangem.features.send.v2.subcomponents.destination import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent.ModelCallback import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM import kotlinx.coroutines.flow.Flow @@ -21,9 +22,10 @@ internal sealed class SendDestinationComponentParams { override val cryptoCurrency: CryptoCurrency, override val userWalletId: UserWalletId, val isBalanceHidingFlow: StateFlow, - val currentRoute: Flow, + val currentRoute: Flow, val callback: ModelCallback, - val isEditMode: Boolean, + val onBackClick: () -> Unit, + val onNextClick: () -> Unit, ) : SendDestinationComponentParams() data class DestinationBlockParams( @@ -32,8 +34,6 @@ internal sealed class SendDestinationComponentParams { override val userWalletId: UserWalletId, override val cryptoCurrency: CryptoCurrency, val blockClickEnableFlow: StateFlow, - val predefinedAddressValue: String?, - val predefinedMemoValue: String?, - val isPredefinedValues: Boolean, + val predefinedValues: PredefinedValues, ) : SendDestinationComponentParams() } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 510030607b..b2e71ae80a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -3,7 +3,6 @@ package com.tangem.features.send.v2.subcomponents.destination.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -22,13 +21,14 @@ import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.SendRoute import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource import com.tangem.features.send.v2.subcomponents.destination.analytics.SendDestinationAnalyticEvents import com.tangem.features.send.v2.subcomponents.destination.model.transformers.* @@ -53,7 +53,6 @@ internal class SendDestinationModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, - private val appRouter: AppRouter, private val validateWalletAddressUseCase: ValidateWalletAddressUseCase, private val validateWalletMemoUseCase: ValidateWalletMemoUseCase, private val getWalletsUseCase: GetWalletsUseCase, @@ -85,19 +84,20 @@ internal class SendDestinationModel @Inject constructor( } private fun initialState() { - if ((uiState.value as? DestinationUM.Content)?.isInitialized == false) { + if ((uiState.value as? DestinationUM.Content)?.isInitialized == false || uiState.value is DestinationUM.Empty) { _uiState.update( SendDestinationInitialStateTransformer( cryptoCurrency = cryptoCurrency, isInitialized = true, ), ) - val params = params as? SendDestinationComponentParams.DestinationBlockParams - if (params?.predefinedAddressValue != null) { + val params = params as? DestinationBlockParams + val predefinedValues = params?.predefinedValues as? PredefinedValues.Content.Deeplink + if (predefinedValues?.address != null) { _uiState.update( SendDestinationPredefinedStateTransformer( - address = params.predefinedAddressValue, - memo = params.predefinedMemoValue, + address = predefinedValues.address, + memo = predefinedValues.memo, ), ) } @@ -269,7 +269,10 @@ internal class SendDestinationModel @Inject constructor( private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean, isValidMemo: Boolean) { val isRecent = type == EnterAddressSource.RecentAddress - if (isRecent && isValidAddress && isValidMemo) onNextClick() + if (isRecent && isValidAddress && isValidMemo) { + saveResult() + (params as? SendDestinationComponentParams.DestinationParams)?.onNextClick?.invoke() + } } private fun saveResult() { @@ -277,15 +280,6 @@ internal class SendDestinationModel @Inject constructor( params.callback.onDestinationResult(uiState.value) } - private fun onNextClick() { - saveResult() - if ((params as? SendDestinationComponentParams.DestinationParams)?.isEditMode == true) { - router.pop() - } else { - router.push(SendRoute.Amount(isEditMode = false)) - } - } - private fun configDestinationNavigation() { val params = params as? SendDestinationComponentParams.DestinationParams ?: return combine( @@ -303,9 +297,7 @@ internal class SendDestinationModel @Inject constructor( R.drawable.ic_close_24 }, backIconClick = { - if (route.isEditMode) { - router.pop() - } else { + if (!route.isEditMode) { analyticsEventHandler.send( SendAnalyticEvents.CloseButtonClicked( source = SendScreenSource.Address, @@ -313,8 +305,8 @@ internal class SendDestinationModel @Inject constructor( isValid = state.isPrimaryButtonEnabled, ), ) - appRouter.pop() } + params.onBackClick() }, additionalIconRes = R.drawable.ic_qrcode_scan_24, additionalIconClick = ::onQrCodeScanClick, @@ -325,7 +317,10 @@ internal class SendDestinationModel @Inject constructor( resourceReference(R.string.common_next) }, isEnabled = state.isPrimaryButtonEnabled, - onClick = ::onNextClick, + onClick = { + saveResult() + params.onNextClick() + }, ), prevButton = null, secondaryPairButtonsUM = null, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt index 5b7cdce04a..bfb3330873 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt @@ -13,7 +13,7 @@ import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM internal class SendFeeComponent( appComponentContext: AppComponentContext, - private val params: SendFeeComponentParams.FeeParams, + params: SendFeeComponentParams.FeeParams, ) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: SendFeeModel = getOrCreateModel(params = params) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt index 82c9f88081..60b98fffbf 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt @@ -1,9 +1,12 @@ package com.tangem.features.send.v2.subcomponents.fee +import arrow.core.Either +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.wallets.models.UserWallet -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -19,6 +22,7 @@ internal sealed class SendFeeComponentParams { abstract val appCurrency: AppCurrency abstract val sendAmount: BigDecimal abstract val destinationAddress: String + abstract val onLoadFee: suspend () -> Either data class FeeParams( override val state: FeeUM, @@ -29,8 +33,10 @@ internal sealed class SendFeeComponentParams { override val appCurrency: AppCurrency, override val sendAmount: BigDecimal, override val destinationAddress: String, - val currentRoute: Flow, + override val onLoadFee: suspend () -> Either, + val currentRoute: Flow, val callback: SendFeeComponent.ModelCallback, + val onNextClick: () -> Unit, ) : SendFeeComponentParams() data class FeeBlockParams( @@ -42,6 +48,7 @@ internal sealed class SendFeeComponentParams { override val appCurrency: AppCurrency, override val sendAmount: BigDecimal, override val destinationAddress: String, + override val onLoadFee: suspend () -> Either, val blockClickEnableFlow: StateFlow, ) : SendFeeComponentParams() } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt index 6f2014fce0..5bc3264f89 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt @@ -6,12 +6,10 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler 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.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener @@ -30,7 +28,6 @@ import com.tangem.utils.coroutines.saveIn import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import java.math.BigDecimal import java.util.Locale import javax.inject.Inject @@ -40,9 +37,7 @@ import javax.inject.Inject internal class SendFeeModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, private val isFeeApproximateUseCase: IsFeeApproximateUseCase, - private val getFeeUseCase: GetFeeUseCase, private val urlOpener: UrlOpener, private val analyticsEventHandler: AnalyticsEventHandler, private val sendFeeAlertFactory: SendFeeAlertFactory, @@ -76,10 +71,32 @@ internal class SendFeeModel @Inject constructor( } override fun feeReload() { - loadFee( - amountValue = params.sendAmount, - destinationAddress = params.destinationAddress, - ) + modelScope.launch { + val isShowLoading = (uiState.value as? FeeUM.Content)?.feeSelectorUM !is FeeSelectorUM.Content + if (isShowLoading) { + _uiState.update(SendFeeLoadingTransformer) + } + params.onLoadFee().fold( + ifRight = { + _uiState.update( + SendFeeLoadedTransformer( + fees = it, + clickIntents = this@SendFeeModel, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + isFeeApproximate = isFeeApproximate(it.normal.amount.type), + ), + ) + updateFeeNotifications() + }, + ifLeft = { feeError -> + if (isShowLoading) { + _uiState.update(SendFeeFailedTransformer(feeError)) + } + updateFeeNotifications() + }, + ) + }.saveIn(feeJobHolder) } override fun onFeeSelectorClick(feeType: FeeType) { @@ -140,10 +157,10 @@ internal class SendFeeModel @Inject constructor( ), ) - navigate() + saveResult() } } else { - navigate() + saveResult() } } @@ -161,12 +178,7 @@ internal class SendFeeModel @Inject constructor( private fun subscribeOnFeeReloadTriggerUpdates() { feeReloadListener.reloadTriggerFlow - .onEach { (amount, destination) -> - loadFee( - amountValue = amount ?: params.sendAmount, - destinationAddress = destination ?: params.destinationAddress, - ) - } + .onEach { feeReload() } .launchIn(modelScope) } @@ -188,48 +200,9 @@ internal class SendFeeModel @Inject constructor( params.callback.onFeeResult(uiState.value) } - private fun loadFee(amountValue: BigDecimal, destinationAddress: String) { - modelScope.launch { - val isShowLoading = (uiState.value as? FeeUM.Content)?.feeSelectorUM !is FeeSelectorUM.Content - if (isShowLoading) { - _uiState.update(SendFeeLoadingTransformer) - } - getFeeUseCase.invoke( - amount = amountValue, - destination = destinationAddress, - userWallet = params.userWallet, - cryptoCurrency = cryptoCurrencyStatus.currency, - ).fold( - ifRight = { - _uiState.update( - SendFeeLoadedTransformer( - fees = it, - clickIntents = this@SendFeeModel, - appCurrency = appCurrency, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - isFeeApproximate = isFeeApproximate(it.normal.amount.type), - ), - ) - updateFeeNotifications() - }, - ifLeft = { feeError -> - if (isShowLoading) { - _uiState.update(SendFeeFailedTransformer(feeError)) - } - updateFeeNotifications() - }, - ) - }.saveIn(feeJobHolder) - } - private fun checkLoadFee() { modelScope.launch { - getFeeUseCase.invoke( - amount = params.sendAmount, - destination = params.destinationAddress, - userWallet = params.userWallet, - cryptoCurrency = cryptoCurrencyStatus.currency, - ).fold( + params.onLoadFee().fold( ifRight = { sendFeeAlertFactory.getFeeUpdatedAlert( newFee = it, @@ -283,11 +256,6 @@ internal class SendFeeModel @Inject constructor( ) } - private fun navigate() { - saveResult() - router.pop() - } - private fun configFeeNavigation() { val params = params as? SendFeeComponentParams.FeeParams ?: return combine( @@ -300,11 +268,14 @@ internal class SendFeeModel @Inject constructor( title = resourceReference(R.string.common_fee_selector_title), subtitle = null, backIconRes = R.drawable.ic_back_24, - backIconClick = router::pop, + backIconClick = params.onNextClick, primaryButton = ButtonsUM.PrimaryButtonUM( text = resourceReference(R.string.common_continue), isEnabled = state.isPrimaryButtonEnabled, - onClick = ::onNextClick, + onClick = { + onNextClick() + params.onNextClick() + }, ), prevButton = null, secondaryPairButtonsUM = null, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt index 14760575ab..4f163c1a61 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt @@ -2,19 +2,17 @@ package com.tangem.features.send.v2.subcomponents.notifications import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Modifier -import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.send.v2.subcomponents.notifications +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationsModel import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.StateFlow -import java.math.BigDecimal internal class NotificationsComponent( appComponentContext: AppComponentContext, @@ -45,12 +43,6 @@ internal class NotificationsComponent( val cryptoCurrencyStatus: CryptoCurrencyStatus, val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val appCurrency: AppCurrency, - val destinationAddress: String, - val memo: String?, - val amountValue: BigDecimal, - val reduceAmountBy: BigDecimal, - val isIgnoreReduce: Boolean, - val fee: Fee?, - val feeError: GetFeeError?, + val notificationData: NotificationData, ) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 6775e4a9b6..127a253df0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -79,13 +79,7 @@ internal class NotificationsModel @Inject constructor( private val currency = cryptoCurrencyStatus.currency private val appCurrency = params.appCurrency - private var destinationAddress = params.destinationAddress - private var memo = params.memo - private var amountValue = params.amountValue - private var reduceAmountBy = params.reduceAmountBy - private var isIgnoreReduce = params.isIgnoreReduce - private var fee = params.fee - private var feeError = params.feeError + private var notificationData = params.notificationData private val _uiState = MutableStateFlow>(persistentListOf()) val uiState = _uiState.asStateFlow() @@ -111,14 +105,7 @@ internal class NotificationsModel @Inject constructor( } private suspend fun updateState(data: NotificationData) { - destinationAddress = data.destinationAddress - memo = data.memo - amountValue = data.amountValue - reduceAmountBy = data.reduceAmountBy - isIgnoreReduce = data.isIgnoreReduce - fee = data.fee - feeError = data.feeError - + notificationData = data buildNotifications() } @@ -127,26 +114,20 @@ internal class NotificationsModel @Inject constructor( addFeeUnreachableNotification( tokenStatus = cryptoCurrencyStatus, coinStatus = feeCryptoCurrencyStatus, - feeError = feeError, + feeError = notificationData.feeError, onReload = { modelScope.launch { sendFeeReloadTrigger.triggerUpdate( feeData = SendFeeData( - amount = amountValue, - destinationAddress = destinationAddress, + amount = notificationData.amountValue, + destinationAddress = notificationData.destinationAddress, ), ) } }, onClick = ::showTokenDetails, ) - addDomainNotifications( - destinationAddress = destinationAddress, - memo = memo, - amountValue = amountValue, - reduceAmountBy = reduceAmountBy, - fee = fee, - ) + addDomainNotifications() } notificationsUpdateTrigger.callbackHasError(notifications.any { it is NotificationUM.Error }) @@ -167,13 +148,7 @@ internal class NotificationsModel @Inject constructor( } } - private suspend fun MutableList.addDomainNotifications( - destinationAddress: String, - memo: String?, - amountValue: BigDecimal, - reduceAmountBy: BigDecimal, - fee: Fee?, - ) { + private suspend fun MutableList.addDomainNotifications() = with(notificationData) { val balance = cryptoCurrencyStatus.value.amount ?: return val feeValue = fee?.amount?.value ?: return val isFeeCoverage = checkFeeCoverage( @@ -311,7 +286,7 @@ internal class NotificationsModel @Inject constructor( ) { val validationError = validateTransactionUseCase( userWalletId = userWalletId, - amount = amountValue.convertToSdkAmount(cryptoCurrencyStatus.currency), + amount = enteredAmount.convertToSdkAmount(currency), fee = fee, memo = memo, destination = destinationAddress, @@ -359,7 +334,7 @@ internal class NotificationsModel @Inject constructor( addHighFeeWarningNotification( enteredAmountValue = enteredAmount, cryptoCurrencyStatus = cryptoCurrencyStatus, - ignoreAmountReduce = isIgnoreReduce, + ignoreAmountReduce = notificationData.isIgnoreReduce, onReduceClick = { reduceBy, reduceByDiff, _ -> modelScope.launch { sendAmountReduceTrigger.triggerReduceBy( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt index 9f4e696e39..fbcd15cc1f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt @@ -92,9 +92,9 @@ internal class SendModel @Inject constructor( private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, - private val getFeeUseCase: GetFeeUseCase, + private val getTransferFeeUseCase: GetTransferFeeUseCase, private val sendTransactionUseCase: SendTransactionUseCase, - private val createTransactionUseCase: CreateTransactionUseCase, + private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val validateWalletAddressUseCase: ValidateWalletAddressUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, @@ -826,7 +826,7 @@ internal class SendModel @Inject constructor( val recipientState = uiState.value.getRecipientState(isFromConfirmation) ?: return null val amount = amountState.amountTextField.cryptoAmount.value ?: return null - return getFeeUseCase.invoke( + return getTransferFeeUseCase.invoke( amount = amount, destination = recipientState.addressTextField.value, userWallet = userWallet, @@ -929,7 +929,7 @@ internal class SendModel @Inject constructor( ) modelScope.launch { - createTransactionUseCase( + createTransferTransactionUseCase( amount = receivingAmount.convertToSdkAmount(cryptoCurrency), fee = fee, memo = memo, 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 63648d1762..d122cd48bf 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 @@ -302,12 +302,13 @@ internal class StakingModel @Inject constructor( ) modelScope.launch { feeLoader.getFee( - onStakingFee = { gasEstimate -> + onStakingFee = { gasEstimate, isFeeApproximate -> stateController.update( SetConfirmationStateAssentTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = gasEstimate, + isFeeApproximate = isFeeApproximate, cryptoCurrencyStatus = cryptoCurrencyStatus, ), ) @@ -363,13 +364,14 @@ internal class StakingModel @Inject constructor( stakingEventFactory.createSendTransactionErrorAlert(error) stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) }, - onFeeIncreased = { increasedFee -> + onFeeIncreased = { increasedFee, isFeeApproximate -> stateController.updateAll( SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), SetConfirmationStateAssentTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = increasedFee, + isFeeApproximate = isFeeApproximate, cryptoCurrencyStatus = cryptoCurrencyStatus, ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt index dc8db5482a..da6dc0fcff 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt @@ -2,22 +2,28 @@ package com.tangem.features.staking.impl.presentation.state.converters import com.tangem.core.ui.extensions.* 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.utils.BigDecimalFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.BalanceItem import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.domain.staking.model.stakekit.BalanceType.Companion.isClickable import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.domain.staking.utils.getRewardStakingBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.toPersistentList import org.joda.time.DateTime +import java.math.BigDecimal import java.util.Calendar internal class BalanceItemConverter( @@ -33,8 +39,7 @@ internal class BalanceItemConverter( val validator = yield.validators.firstOrNull { value.validatorAddress?.contains(it.address, ignoreCase = true) == true } - - val cryptoAmount = value.amount + val cryptoAmount = value.getBalanceValue() val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) val title = value.type.getTitle(validator?.name) @@ -52,20 +57,41 @@ internal class BalanceItemConverter( ), fiatAmount = fiatAmount, formattedFiatAmount = stringReference( - BigDecimalFormatter.formatFiatAmount( - fiatAmount = fiatAmount, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ), + fiatAmount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, ), rawCurrencyId = value.rawCurrencyId, pendingActions = value.pendingActions.toPersistentList(), - isClickable = value.type.isClickable() && !value.isPending, + isClickable = value.isClickable(), isPending = value.isPending, ) } } + private fun BalanceItem.getBalanceValue(): BigDecimal { + val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( + blockchainId = cryptoCurrencyStatus.currency.network.id.value, + ) + val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data + return if (isIncludeStakingTotalBalance) { + amount + } else { + amount - yieldBalance?.getRewardStakingBalance().orZero() + } + } + + private fun BalanceItem.isClickable(): Boolean { + val networkId = cryptoCurrencyStatus.currency.network.id.value + return when { + isTon(networkId) -> pendingActions.any { it.type == StakingActionType.WITHDRAW } + else -> this.type.isClickable() && !this.isPending + } + } + private fun BalanceType.getTitle(validatorName: String?) = when (this) { BalanceType.PREPARING, BalanceType.STAKED, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt index 1688f31bdf..8043052fe7 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt @@ -18,6 +18,7 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.staking.impl.presentation.state.StakingStateController import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -38,13 +39,14 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( private val stateController: StakingStateController, private val getFeeUseCase: GetFeeUseCase, private val estimateGasUseCase: EstimateGasUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, @Assisted private val yield: Yield, ) { suspend fun getFee( - onStakingFee: (Fee) -> Unit, + onStakingFee: (Fee, Boolean) -> Unit, onStakingFeeError: (StakingError) -> Unit, onApprovalFee: (TransactionFee) -> Unit, onFeeError: (GetFeeError) -> Unit, @@ -91,7 +93,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( amount: BigDecimal, validatorAddress: String, onStakingFeeError: (StakingError) -> Unit, - onStakingFee: (Fee) -> Unit, + onStakingFee: (Fee, Boolean) -> Unit, ) { val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value ?: error("No available address") @@ -144,14 +146,14 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( } } + val amount = Amount( + currencySymbol = gasEstimate.token.symbol, + value = gasEstimate.amount, + decimals = gasEstimate.token.decimals, + ) onStakingFee( - Fee.Common( - Amount( - currencySymbol = gasEstimate.token.symbol, - value = gasEstimate.amount, - decimals = gasEstimate.token.decimals, - ), - ), + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt index 27d87225e0..aa0afb181b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -5,7 +5,10 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionSender import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.domain.staking.* +import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase +import com.tangem.domain.staking.GetStakingTransactionsUseCase +import com.tangem.domain.staking.SaveUnsubmittedHashUseCase +import com.tangem.domain.staking.SubmitHashUseCase import com.tangem.domain.staking.model.SubmitHashData import com.tangem.domain.staking.model.stakekit.NetworkType import com.tangem.domain.staking.model.stakekit.PendingAction @@ -19,6 +22,7 @@ import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionTy import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount @@ -49,6 +53,7 @@ internal class StakingTransactionSender @AssistedInject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val submitHashUseCase: SubmitHashUseCase, private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, @Assisted private val yield: Yield, @@ -63,7 +68,7 @@ internal class StakingTransactionSender @AssistedInject constructor( onConstructError: (StakingError) -> Unit, onSendSuccess: (String) -> Unit, onSendError: (SendTransactionError?) -> Unit, - onFeeIncreased: (Fee) -> Unit, + onFeeIncreased: (Fee, Boolean) -> Unit, ) { val state = stateController.value @@ -101,10 +106,10 @@ internal class StakingTransactionSender @AssistedInject constructor( onSendError = onSendError, ) } else { + val amount = fee.amount.copy(value = totalFee) onFeeIncreased( - Fee.Common( - fee.amount.copy(value = totalFee), - ), + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), ) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt index 56e01d3a83..c9a8054cc5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt @@ -13,6 +13,7 @@ internal class SetConfirmationStateAssentTransformer( private val appCurrencyProvider: Provider, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, private val fee: Fee, + private val isFeeApproximate: Boolean, private val cryptoCurrencyStatus: CryptoCurrencyStatus, ) : Transformer { @@ -31,7 +32,7 @@ internal class SetConfirmationStateAssentTransformer( rate = feeCryptoCurrencyStatus?.value?.fiatRate, isFeeConvertibleToFiat = isFeeConvertibleToFiat, appCurrency = appCurrencyProvider(), - isFeeApproximate = false, + isFeeApproximate = isFeeApproximate, ), isPrimaryButtonEnabled = with(cryptoCurrencyStatus.value) { sources.yieldBalanceSource.isActual() && sources.networkSource.isActual() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt index 510266948e..9ddc12135c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt @@ -5,8 +5,10 @@ import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.SOLANA_SCHEDULE +import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.TON_SCHEDULE import com.tangem.lib.crypto.BlockchainUtils.isCosmos import com.tangem.lib.crypto.BlockchainUtils.isSolana +import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.StringsSigns.MINUS import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE @@ -14,6 +16,7 @@ import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE private data object StakingRewardSchedule { val COSMOS_SCHEDULE = 5 to 12 val SOLANA_SCHEDULE = 2 to 3 + val TON_SCHEDULE = 1 to 2 } internal fun getRewardScheduleText( @@ -71,6 +74,15 @@ private fun getCustomRewardSchedule(networkId: String, decapitalize: Boolean = f ) } isTron(networkId) -> resourceReference(id = R.string.staking_reward_schedule_day, decapitalize = decapitalize) + isTon(networkId) -> combinedReference( + resourceReference(id = R.string.staking_reward_schedule_each_plural, decapitalize = decapitalize), + stringReference(NON_BREAKING_SPACE.toString()), + stringReference("${TON_SCHEDULE.first}$MINUS${TON_SCHEDULE.second}$NON_BREAKING_SPACE"), + pluralReference( + id = R.plurals.common_days_no_param, + count = TON_SCHEDULE.second, + ), + ) else -> null } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 8e465b9c2b..ac037efc96 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -21,9 +21,12 @@ import com.tangem.datasource.api.express.models.response.SwapPair import com.tangem.datasource.api.express.models.response.SwapPairsWithProviders import com.tangem.datasource.api.express.models.response.TxDetails import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.exchangeservice.swap.ExpressUtils +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.api.SwapRepository @@ -48,6 +51,7 @@ internal class DefaultSwapRepository( private val userWalletsListManager: UserWalletsListManager, private val errorsDataConverter: ErrorsDataConverter, private val dataSignatureVerifier: DataSignatureVerifier, + private val appPreferencesStore: AppPreferencesStore, moshi: Moshi, excludedBlockchains: ExcludedBlockchains, ) : SwapRepository { @@ -60,6 +64,7 @@ internal class DefaultSwapRepository( private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java) override suspend fun getPairs( + userWallet: UserWallet, initialCurrency: LeastTokenInfo, currencyList: List, ): PairsWithProviders { @@ -73,6 +78,7 @@ internal class DefaultSwapRepository( val pairsDeferred = async { getPairsInternal( + userWallet = userWallet, from = arrayListOf(initial), to = currenciesList, ) @@ -80,6 +86,7 @@ internal class DefaultSwapRepository( val reversedPairsDeferred = async { getPairsInternal( + userWallet = userWallet, from = currenciesList, to = arrayListOf(initial), ) @@ -90,7 +97,13 @@ internal class DefaultSwapRepository( val allPairs = pairs + reversedPairs - val providers = tangemExpressApi.getProviders().getOrThrow() + val providers = tangemExpressApi.getProviders( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + ).getOrThrow() return@withContext swapPairInfoConverter.convert( SwapPairsWithProviders( @@ -109,6 +122,7 @@ internal class DefaultSwapRepository( } override suspend fun getPairsOnly( + userWallet: UserWallet, initialCurrency: LeastTokenInfo, currencyList: List, ): PairsWithProviders { @@ -122,6 +136,7 @@ internal class DefaultSwapRepository( val pairsDeferred = async { getPairsInternal( + userWallet = userWallet, from = arrayListOf(initial), to = currenciesList, ) @@ -129,6 +144,7 @@ internal class DefaultSwapRepository( val reversedPairsDeferred = async { getPairsInternal( + userWallet = userWallet, from = currenciesList, to = arrayListOf(initial), ) @@ -156,25 +172,42 @@ internal class DefaultSwapRepository( } private suspend fun getPairsInternal( + userWallet: UserWallet, from: List, to: List, ): ApiResponse> { return tangemExpressApi.getPairs( - PairsRequestBody( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + body = PairsRequestBody( from = from, to = to, ), ) } - override suspend fun getExchangeStatus(txId: String): Either { + override suspend fun getExchangeStatus( + userWallet: UserWallet, + txId: String, + ): Either { return withContext(coroutineDispatcher.io) { either { catch( block = { exchangeStatusConverter.convert( tangemExpressApi - .getExchangeStatus(txId) + .getExchangeStatus( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + txId = txId, + ) .getOrThrow(), ) }, @@ -188,7 +221,7 @@ internal class DefaultSwapRepository( } override suspend fun findBestQuote( - userWalletId: UserWalletId, + userWallet: UserWallet, fromContractAddress: String, fromNetwork: String, toContractAddress: String, @@ -211,6 +244,11 @@ internal class DefaultSwapRepository( toDecimals = toDecimals, providerId = providerId, rateType = rateType.name.lowercase(), + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), ).getOrThrow() QuoteModel( toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals), @@ -223,6 +261,7 @@ internal class DefaultSwapRepository( } override suspend fun getExchangeData( + userWallet: UserWallet, fromContractAddress: String, fromNetwork: String, toContractAddress: String, @@ -255,6 +294,11 @@ internal class DefaultSwapRepository( requestId = requestId, refundAddress = refundAddress, refundExtraId = refundExtraId, + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), ).getOrThrow() if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) { val txDetails = parseTxDetails(response.txDetailsJson) @@ -281,6 +325,7 @@ internal class DefaultSwapRepository( } override suspend fun exchangeSent( + userWallet: UserWallet, txId: String, fromNetwork: String, fromAddress: String, @@ -290,7 +335,12 @@ internal class DefaultSwapRepository( ): Either = withContext(coroutineDispatcher.io) { try { tangemExpressApi.exchangeSent( - ExchangeSentRequestBody( + userWalletId = userWallet.walletId.stringValue, + refCode = ExpressUtils.getRefCode( + userWallet = userWallet, + appPreferencesStore = appPreferencesStore, + ), + body = ExchangeSentRequestBody( txId = txId, fromNetwork = fromNetwork, fromAddress = fromAddress, @@ -344,47 +394,6 @@ internal class DefaultSwapRepository( ) } - override suspend fun getApproveData( - userWalletId: UserWalletId, - networkId: String, - derivationPath: String?, - currency: CryptoCurrency, - amount: BigDecimal?, - spenderAddress: String, - ): String { - val blockchain = - requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = blockchain, - derivationPath = derivationPath, - ) - - return (walletManager as? Approver)?.getApproveData( - spenderAddress, - amount?.let { convertToAmount(it, currency) }, - ) ?: error("Cannot cast to Approver") - } - - private fun convertToAmount(amount: BigDecimal, currency: CryptoCurrency): Amount { - return Amount( - currencySymbol = currency.symbol, - value = amount, - decimals = currency.decimals, - type = if (currency is CryptoCurrency.Token) { - AmountType.Token( - Token( - symbol = currency.symbol, - contractAddress = currency.contractAddress, - decimals = currency.decimals, - ), - ) - } else { - AmountType.Coin - }, - ) - } - override fun getNativeTokenForNetwork(networkId: String): CryptoCurrency { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 082f1b8ec9..87e009b03a 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -7,8 +7,8 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync import com.tangem.datasource.local.preferences.utils.getObjectMapSync -import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.converters.SavedSwapTransactionListConverter import com.tangem.feature.swap.domain.SwapTransactionRepository @@ -80,9 +80,8 @@ internal class DefaultSwapTransactionRepository( } override suspend fun getTransactions( - userWalletId: UserWalletId, + userWallet: UserWallet, cryptoCurrencyId: CryptoCurrency.ID, - scanResponse: ScanResponse, ): Flow?> { return withContext(dispatchers.io) { val txStatuses = appPreferencesStore.getObjectMapSync( @@ -93,7 +92,7 @@ internal class DefaultSwapTransactionRepository( ).map { savedTransactions -> val currencyTxs = savedTransactions ?.filter { - it.userWalletId == userWalletId.stringValue && + it.userWalletId == userWallet.walletId.stringValue && ( it.toCryptoCurrencyId == cryptoCurrencyId.value || it.fromCryptoCurrencyId == cryptoCurrencyId.value @@ -103,7 +102,7 @@ internal class DefaultSwapTransactionRepository( currencyTxs?.mapNotNull { converter.convertBack( value = it, - scanResponse = scanResponse, + scanResponse = userWallet.scanResponse, txStatuses = txStatuses, ) } diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index 5a1b3dcf85..eb0e016c58 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -36,6 +36,7 @@ internal class SwapDataModule { errorsDataConverter: ErrorsDataConverter, @NetworkMoshi moshi: Moshi, excludedBlockchains: ExcludedBlockchains, + appPreferencesStore: AppPreferencesStore, ): SwapRepository { return DefaultSwapRepository( tangemExpressApi = tangemExpressApi, @@ -46,6 +47,7 @@ internal class SwapDataModule { dataSignatureVerifier = dataSignature, moshi = moshi, excludedBlockchains = excludedBlockchains, + appPreferencesStore = appPreferencesStore, ) } diff --git a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index 46ebd1d768..69b15ea0b7 100644 --- a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -2,6 +2,7 @@ package com.tangem.feature.swap.domain.api import arrow.core.Either import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.* @@ -9,16 +10,24 @@ import java.math.BigDecimal interface SwapRepository { - suspend fun getPairs(initialCurrency: LeastTokenInfo, currencyList: List): PairsWithProviders + suspend fun getPairs( + userWallet: UserWallet, + initialCurrency: LeastTokenInfo, + currencyList: List, + ): PairsWithProviders /** Express getPairs request variant without providers request */ - suspend fun getPairsOnly(initialCurrency: LeastTokenInfo, currencyList: List): PairsWithProviders + suspend fun getPairsOnly( + userWallet: UserWallet, + initialCurrency: LeastTokenInfo, + currencyList: List, + ): PairsWithProviders - suspend fun getExchangeStatus(txId: String): Either + suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): Either @Suppress("LongParameterList") suspend fun findBestQuote( - userWalletId: UserWalletId, + userWallet: UserWallet, fromContractAddress: String, fromNetwork: String, toContractAddress: String, @@ -41,19 +50,9 @@ interface SwapRepository { spenderAddress: String, ): BigDecimal - @Suppress("LongParameterList") - @Throws(IllegalStateException::class) - suspend fun getApproveData( - userWalletId: UserWalletId, - networkId: String, - derivationPath: String?, - currency: CryptoCurrency, - amount: BigDecimal?, - spenderAddress: String, - ): String - @Suppress("LongParameterList") suspend fun getExchangeData( + userWallet: UserWallet, fromContractAddress: String, fromNetwork: String, toContractAddress: String, @@ -72,6 +71,7 @@ interface SwapRepository { // TODO: Add target error handling, remove either ([REDACTED_JIRA]) @Suppress("LongParameterList") suspend fun exchangeSent( + userWallet: UserWallet, txId: String, fromNetwork: String, fromAddress: String, diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 14278f36c8..7d52289e23 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -1,5 +1,3 @@ -import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants - plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) @@ -23,22 +21,24 @@ dependencies { /** Domain */ implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.card) + implementation(projects.domain.demo) + implementation(projects.domain.legacy) implementation(projects.domain.models) + implementation(projects.domain.quotes) + implementation(projects.domain.staking) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) + implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) + implementation(projects.domain.txhistory.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) - implementation(projects.domain.transaction) - implementation(projects.domain.legacy) - implementation(projects.libs.blockchainSdk) - implementation(projects.domain.demo) - implementation(projects.domain.card) - implementation(projects.domain.appCurrency.models) - implementation(projects.domain.txhistory.models) - implementation(projects.domain.transaction.models) + implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) - implementation(projects.domain.staking) + implementation(projects.libs.blockchainSdk) /** Core modules */ implementation(projects.core.utils) diff --git a/features/swap/domain/models/build.gradle.kts b/features/swap/domain/models/build.gradle.kts index 6ebbca292e..e4496dc053 100644 --- a/features/swap/domain/models/build.gradle.kts +++ b/features/swap/domain/models/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.core.datasource) /** Other Libraries **/ + implementation(tangemDeps.blockchain) implementation(deps.kotlin.serialization) implementation(deps.arrow.core) implementation(deps.moshi.kotlin) diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index a4336b8eae..be89782000 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain.models.ui +import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.models.ExpressDataError @@ -82,16 +83,10 @@ data class TokenSwapInfo( data class RequestApproveStateData( val fee: TxFeeState, - val approveData: String, val fromTokenAmount: SwapAmount, val spenderAddress: String, ) -// data class SwapStateData( -// val fee: TxFeeState, -// val swapModel: SwapDataModel, -// ) - sealed class TxFeeState { data class MultipleFeeState( val normalFee: TxFee, @@ -115,34 +110,15 @@ sealed class TxFeeState { data class TxFee( val feeValue: BigDecimal, - val gasLimit: Int, val feeFiatFormatted: String, val feeCryptoFormatted: String, val feeIncludeOtherNativeFee: BigDecimal, val feeFiatFormattedWithNative: String, val feeCryptoFormattedWithNative: String, - val decimals: Int, val cryptoSymbol: String, val feeType: FeeType, - val params: Params?, -) { - - sealed class Params { - - data class Filecoin( - val gasPremium: Long, - ) : Params() - - data class Sui( - val gasBudget: Long, - val gasPrice: Long, - ) : Params() - - data class Hedera( - val additionalHBARFee: BigDecimal, - ) : Params() - } -} + val fee: Fee, +) enum class FeeType { NORMAL, PRIORITY diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt deleted file mode 100644 index ceab0eb503..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.feature.swap.domain.models.domain.NetworkInfo - -interface BlockchainInteractor { - - /** - * In app blockchain id, actual in blockchain sdk, not the same as networkId - * - * workaround till not use backend only and not integrated server vs sdk - */ - fun getBlockchainInfo(networkId: String): NetworkInfo - - fun getExplorerTransactionLink(networkId: String, txHash: String): String -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt deleted file mode 100644 index 284b1572cb..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.feature.swap.domain.models.domain.NetworkInfo -import com.tangem.lib.crypto.TransactionManager -import javax.inject.Inject - -internal class DefaultBlockchainInteractor @Inject constructor( - private val transactionManager: TransactionManager, -) : BlockchainInteractor { - - override fun getBlockchainInfo(networkId: String): NetworkInfo { - return transactionManager.getBlockchainInfo(networkId).let { - NetworkInfo( - name = it.name, - blockchainId = it.blockchainId, - ) - } - } - - override fun getExplorerTransactionLink(networkId: String, txHash: String): String { - return transactionManager.getExplorerTransactionLink(networkId, txHash) - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt index 113c0ca915..28d0e06898 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast @@ -10,9 +11,14 @@ class GetAvailablePairsUseCase( ) { suspend operator fun invoke( + userWallet: UserWallet, initialCurrency: LeastTokenInfo, currencies: List, ): List { - return swapRepository.getPairsOnly(initialCurrency, currencies).pairs + return swapRepository.getPairsOnly( + userWallet = userWallet, + initialCurrency = initialCurrency, + currencyList = currencies, + ).pairs } } \ No newline at end of file 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 29e7ee6c0d..6d119df85d 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 @@ -3,44 +3,43 @@ package com.tangem.feature.swap.domain import arrow.core.Either import arrow.core.getOrElse import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Blockchain.* +import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionExtras +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.* import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.transaction.usecase.* import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.api.SwapRepository -import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.ProxyAmount -import com.tangem.lib.crypto.models.ProxyFee -import com.tangem.lib.crypto.models.ProxyFees import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -52,16 +51,20 @@ import java.math.RoundingMode @Suppress("LargeClass", "LongParameterList") internal class SwapInteractorImpl @AssistedInject constructor( - private val transactionManager: TransactionManager, private val userWalletManager: UserWalletManager, private val repository: SwapRepository, private val allowPermissionsHandler: AllowPermissionsHandler, private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val createTransactionUseCase: CreateTransactionUseCase, + private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, + private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val quotesRepository: QuotesRepository, + private val quotesRepositoryV2: QuotesRepositoryV2, + private val tokensFeatureToggles: TokensFeatureToggles, private val swapTransactionRepository: SwapTransactionRepository, private val currencyChecksRepository: CurrencyChecksRepository, private val appCurrencyRepository: AppCurrencyRepository, @@ -69,6 +72,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, + private val getFeeUseCase: GetFeeUseCase, + private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val amountFormatter: AmountFormatter, @@ -79,7 +84,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( GetSelectedAppCurrencyUseCase(appCurrencyRepository) } - private val swapCurrencyConverter = SwapCurrencyConverter() private val hundredPercent = BigInteger("100") private val userWallet @@ -105,6 +109,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val pairsLeast = getPairs( + userWallet = userWallet, initialCurrency = LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", network = currency.network.backendId, @@ -188,42 +193,32 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getPairs( + userWallet: UserWallet, initialCurrency: LeastTokenInfo, currenciesList: List, ): PairsWithProviders { - return repository.getPairs(initialCurrency, currenciesList) + return repository.getPairs( + userWallet = userWallet, + initialCurrency = initialCurrency, + currencyList = currenciesList, + ) } override suspend fun givePermissionToSwap( networkId: String, permissionOptions: PermissionOptions, ): SwapTransactionState { - val derivationPath = permissionOptions.fromToken.network.derivationPath.value - val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) { - getApproveData( - networkId = networkId, - derivationPath = derivationPath, - fromToken = permissionOptions.fromToken, - spenderAddress = permissionOptions.spenderAddress, - ) - } else { - permissionOptions.approveData.approveData + val amount = permissionOptions.approveData.fromTokenAmount.takeIf { + permissionOptions.approveType == SwapApproveType.LIMITED } - val approveTransaction = createTransactionUseCase( - amount = BigDecimal.ZERO.convertToSdkAmount(permissionOptions.fromToken), - fee = getFeeForTransaction( - fee = permissionOptions.txFee, - blockchain = Blockchain.fromId(permissionOptions.fromToken.network.id.value), - ), - memo = null, - destination = getTokenAddress(permissionOptions.fromToken), - network = permissionOptions.fromToken.network, + + val approveTransaction = createApprovalTransactionUseCase( + fee = permissionOptions.txFee.fee, userWalletId = userWalletId, - txExtras = createDexTxExtras( - dataToSign, - permissionOptions.fromToken.network, - permissionOptions.txFee.gasLimit, - ), + cryptoCurrency = permissionOptions.fromToken as CryptoCurrency.Token, + amount = amount?.value, + contractAddress = permissionOptions.forTokenContractAddress, + spenderAddress = permissionOptions.spenderAddress, ).getOrElse { Timber.e(it, "Failed to create approveTransaction") return SwapTransactionState.Error.UnknownError @@ -311,7 +306,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( isBalanceWithoutFeeEnough: Boolean, ): Pair { val maybeQuotes = repository.findBestQuote( - userWalletId = userWalletId, + userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), fromNetwork = fromToken.currency.network.backendId, toContractAddress = toToken.currency.getContractAddress(), @@ -335,10 +330,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) - transactionManager.updateWalletManager( - networkId, - fromToken.currency.network.derivationPath.value, - ) + fetchCurrencyStatusUseCase(userWalletId, fromToken.currency.id, true) } return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { provider to loadDexSwapData( @@ -535,7 +527,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, - fee = fee, + txFee = fee, amountToSwap = amountToSwap, ) } @@ -590,7 +582,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, amountToSwap: String, - fee: TxFee, + txFee: TxFee, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) @@ -600,16 +592,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( val amountToSend = createNativeAmountForDex(swapData.transaction.txValue, currencyToSendStatus.currency.network) val txData = createTransactionUseCase( amount = amountToSend, - fee = getFeeForTransaction( - fee = fee, - blockchain = Blockchain.fromId(currencyToSendStatus.currency.network.id.value), - ), + fee = txFee.fee, memo = null, destination = swapData.transaction.txTo, userWalletId = userWalletId, network = currencyToSendStatus.currency.network, - txExtras = createDexTxExtras(dataToSign, currencyToSendStatus.currency.network, fee.gasLimit), - hash = dataToSign, + txExtras = createDexTxExtras(dataToSign, currencyToSendStatus.currency.network, txFee.fee.getGasLimit()), ).getOrElse { Timber.e(it, "Failed to create swap dex tx data") return SwapTransactionState.Error.UnknownError @@ -623,6 +611,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return result.fold( ifRight = { txHash -> repository.exchangeSent( + userWallet = userWallet, txId = swapData.transaction.txId, fromNetwork = currencyToSendStatus.currency.network.backendId, fromAddress = currencyToSendStatus.value.networkAddress?.defaultAddress?.value.orEmpty(), @@ -665,7 +654,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( return createTransactionExtrasUseCase( data = data, network = network, - transactionType = TransactionType.APPROVE, gasLimit = gasLimit?.toBigInteger(), ).getOrNull() ?: error("failed to create extras") } @@ -679,6 +667,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapProvider: SwapProvider, ): SwapTransactionState { val exchangeData = repository.getExchangeData( + userWallet = userWallet, fromContractAddress = currencyToSend.currency.getContractAddress(), fromNetwork = currencyToSend.currency.network.backendId, toContractAddress = currencyToGet.currency.getContractAddress(), @@ -701,12 +690,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isDemoCardUseCase(cardId)) return SwapTransactionState.Error.UnknownError - val txData = createTransactionUseCase( + val txData = createTransferTransactionUseCase( amount = amount.value.convertToSdkAmount(currencyToSend.currency), - fee = getFeeForTransaction( - fee = txFee, - blockchain = Blockchain.fromId(currencyToSend.currency.network.id.value), - ), + fee = txFee.fee, memo = exchangeDataCex.txExtraId, destination = exchangeDataCex.txTo, userWalletId = userWalletId, @@ -731,6 +717,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ifLeft = { SwapTransactionState.Error.TransactionError(it) }, ifRight = { txHash -> repository.exchangeSent( + userWallet = userWallet, txId = exchangeDataCex.txId, fromNetwork = currencyToSend.currency.network.backendId, fromAddress = currencyToSend.value.networkAddress?.defaultAddress?.value.orEmpty(), @@ -773,235 +760,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } - @Suppress("LongMethod") - private fun getFeeForTransaction(fee: TxFee, blockchain: Blockchain): Fee { - val feeAmountValue = fee.feeValue - val feeAmount = Amount( - value = fee.feeValue, - currencySymbol = fee.cryptoSymbol, - decimals = fee.decimals, - type = AmountType.Coin, - ) - - return if (blockchain.isEvm()) { - val feeAmountWithDecimals = feeAmountValue.movePointRight(fee.decimals) - - Fee.Ethereum.Legacy( - amount = feeAmount, - gasLimit = fee.gasLimit.toBigInteger(), - gasPrice = (feeAmountWithDecimals / fee.gasLimit.toBigDecimal()).toBigInteger(), - ) - } else { - when (blockchain) { - // region Blockchains with their own fees - VeChain, - VeChainTestnet, - -> { - Fee.VeChain( - amount = feeAmount, - gasPriceCoef = Fee.VeChain.getGasPriceCoef(fee.gasLimit.toLong(), fee.feeValue), - gasLimit = fee.gasLimit.toLong(), - ) - } - Aptos, - AptosTestnet, - -> { - val gasUnitPrice = fee.feeValue.divide( - fee.gasLimit.toBigDecimal(), - Aptos.decimals(), - RoundingMode.HALF_UP, - ) - - Fee.Aptos( - amount = feeAmount, - gasUnitPrice = gasUnitPrice - .movePointRight(Aptos.decimals()) - .toLong(), - gasLimit = fee.gasLimit.toLong(), - ) - } - Filecoin, - -> { - val gasUnitPrice = fee.feeValue.divide( - BigDecimal(fee.gasLimit), - Filecoin.decimals(), - RoundingMode.HALF_UP, - ) - val feeParams = requireNotNull(fee.params as? TxFee.Params.Filecoin) - - Fee.Filecoin( - amount = feeAmount, - gasUnitPrice = gasUnitPrice - .movePointRight(Filecoin.decimals()) - .toLong(), - gasLimit = fee.gasLimit.toLong(), - gasPremium = feeParams.gasPremium, - ) - } - Sui, - SuiTestnet, - -> { - val feeParams = requireNotNull(fee.params as? TxFee.Params.Sui) - - Fee.Sui( - amount = feeAmount, - gasPrice = feeParams.gasPrice, - gasBudget = feeParams.gasBudget, - ) - } - Hedera, - HederaTestnet, - -> { - val feeParams = requireNotNull(fee.params as? TxFee.Params.Hedera) - Fee.Hedera(amount = feeAmount, additionalHBARFee = feeParams.additionalHBARFee) - } - // endregion - // region Blockchains with common fees or EVM-like fees - Unknown, - Arbitrum, - ArbitrumTestnet, - Avalanche, - AvalancheTestnet, - Binance, - BinanceTestnet, - BSC, - BSCTestnet, - Bitcoin, - BitcoinTestnet, - BitcoinCash, - BitcoinCashTestnet, - Cardano, - Cosmos, - CosmosTestnet, - Dogecoin, - Ducatus, - Ethereum, - EthereumTestnet, - EthereumClassic, - EthereumClassicTestnet, - Fantom, - FantomTestnet, - Litecoin, - Near, - NearTestnet, - Polkadot, - PolkadotTestnet, - Kava, - KavaTestnet, - Kusama, - Polygon, - PolygonTestnet, - RSK, - Sei, - SeiTestnet, - Stellar, - StellarTestnet, - Solana, - SolanaTestnet, - Tezos, - Tron, - TronTestnet, - XRP, - Gnosis, - Dash, - Optimism, - OptimismTestnet, - Dischain, - EthereumPow, - EthereumPowTestnet, - Kaspa, - KaspaTestnet, - Telos, - TelosTestnet, - TON, - TONTestnet, - Ravencoin, - RavencoinTestnet, - TerraV1, - TerraV2, - Cronos, - AlephZero, - AlephZeroTestnet, - OctaSpace, - OctaSpaceTestnet, - Chia, - ChiaTestnet, - Decimal, - DecimalTestnet, - XDC, - XDCTestnet, - Playa3ull, - Shibarium, - ShibariumTestnet, - Algorand, - AlgorandTestnet, - Aurora, - AuroraTestnet, - Areon, - AreonTestnet, - PulseChain, - PulseChainTestnet, - ZkSyncEra, - ZkSyncEraTestnet, - Nexa, - NexaTestnet, - Moonbeam, - MoonbeamTestnet, - Manta, - MantaTestnet, - PolygonZkEVM, - PolygonZkEVMTestnet, - Radiant, - Fact0rn, - Base, - BaseTestnet, - Moonriver, - MoonriverTestnet, - Mantle, - MantleTestnet, - Flare, - FlareTestnet, - Taraxa, - TaraxaTestnet, - Koinos, - KoinosTestnet, - Joystream, - Bittensor, - Blast, - BlastTestnet, - Cyber, - CyberTestnet, - InternetComputer, - EnergyWebChain, - EnergyWebChainTestnet, - EnergyWebX, - EnergyWebXTestnet, - Casper, - CasperTestnet, - Core, - CoreTestnet, - Xodex, - Canxium, - Chiliz, - ChilizTestnet, - Alephium, - AlephiumTestnet, - Clore, - VanarChain, - VanarChainTestnet, - OdysseyChain, OdysseyChainTestnet, - Bitrock, BitrockTestnet, - Sonic, SonicTestnet, - ApeChain, ApeChainTestnet, - Scroll, ScrollTestnet, - ZkLinkNova, ZkLinkNovaTestnet, - Pepecoin, PepecoinTestnet, - -> Fee.Common(feeAmount) - // endregion - } - } - } - private suspend fun storeSwapTransaction( currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, @@ -1083,11 +841,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createEmptyAmountState(): SwapState { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() return SwapState.EmptyAmountState( - zeroAmountEquivalent = BigDecimalFormatter.formatFiatAmount( - fiatAmount = BigDecimal.ZERO, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ), + zeroAmountEquivalent = BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, ) } @@ -1136,7 +895,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val quotes = repository.findBestQuote( - userWalletId = userWalletId, + userWallet = userWallet, fromContractAddress = fromToken.getContractAddress(), fromNetwork = fromToken.network.backendId, toContractAddress = toToken.getContractAddress(), @@ -1369,11 +1128,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( val rates = getQuotes(feeCurrencyId) return rates[feeCurrencyId]?.fiatRate?.let { rate -> fees.map { fee -> - BigDecimalFormatter.formatFiatAmount( - fiatAmount = rate.multiply(fee), - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) + rate.multiply(fee).format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } } }.orEmpty() } @@ -1391,6 +1151,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( selectedFee: FeeType, ): SwapState { return repository.getExchangeData( + userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), fromNetwork = fromToken.currency.network.backendId, toContractAddress = toToken.currency.getContractAddress(), @@ -1411,17 +1172,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( val otherNativeFee = transaction.otherNativeFeeWei ?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO - val txFeeState = when ( - val feeData = getFeeDataForDexSwap( - networkId = networkId, - transaction = transaction, - fromToken = fromToken.currency, - cardId = userWallet.scanResponse.card.cardId, - ) - ) { - is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken.currency, otherNativeFee) - is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken.currency, otherNativeFee) - } + val txFeeState = getFeeDataForDexSwap( + network = fromToken.currency.network, + transaction = transaction, + fromToken = fromToken.currency, + ) + .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + .toTxFeeState(fromToken.currency, otherNativeFee) + val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState) val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO) @@ -1485,17 +1243,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getFeeDataForDexSwap( - networkId: String, + network: Network, transaction: ExpressTransactionModel.DEX, fromToken: CryptoCurrency, - cardId: String?, - ): ProxyFees { - if (cardId != null && isDemoCardUseCase(cardId)) { - return getDemoFees(fromToken) - } + ): TransactionFee { return try { val nativeBalance = userWalletManager.getNativeTokenBalance( - networkId = networkId, + networkId = network.backendId, derivationPath = fromToken.network.derivationPath.value, ) ?: ProxyAmount.empty() val amountToSend = createNativeAmountForDex(transaction.txValue, fromToken.network) @@ -1503,21 +1257,30 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (nativeBalance.value < amountToSend.value) { error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value") } - transactionManager.getFee( - networkId = networkId, - amountToSend = amountToSend, - currencyToSend = swapCurrencyConverter.convert(fromToken), - destinationAddress = transaction.txTo, - increaseBy = INCREASE_GAS_LIMIT_BY, + + val extras = createTransactionExtrasUseCase( data = transaction.txData, - derivationPath = fromToken.network.derivationPath.value, + network = network, + ).getOrNull() ?: error("unable to create extras") + + val transactionData = TransactionData.Uncompiled( + amount = amountToSend, + destinationAddress = transaction.txTo, + fee = null, + sourceAddress = transaction.txFrom, + extras = extras, ) + getFeeUseCase( + transactionData = transactionData, + network = network, + userWallet = userWallet, + ).getOrNull() ?: error("unable to calculate fee") } catch (e: IllegalStateException) { - transactionManager.getFeeForGas( - networkId = networkId, - gas = transaction.gas.multiply(INCREASE_GAS_LIMIT_BY.toBigInteger()).divide(100.toBigInteger()), - derivationPath = fromToken.network.derivationPath.value, - ) + getEthSpecificFeeUseCase( + userWallet = userWallet, + cryptoCurrency = fromToken, + gasLimit = transaction.gas, + ).getOrNull() ?: error("can't get fee for getEthSpecificFeeUseCase") } } @@ -1568,7 +1331,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( ): TxFeeState { return txFeeResult?.fold( ifLeft = { TxFeeState.Empty }, - ifRight = { txFee -> txFee.toTxFeeState(fromToken.currency, null) }, + ifRight = { txFee -> + txFee + .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) + .toTxFeeState(fromToken.currency, null) + }, ) ?: TxFeeState.Empty } @@ -1613,42 +1380,47 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val derivationPath = fromToken.network.derivationPath.value // setting up amount for approve with given amount for swap [SwapApproveType.Limited] - val transactionData = getApproveData( - networkId = networkId, - derivationPath = derivationPath, - fromToken = fromToken, - swapAmount = swapAmount, + val callData = SmartContractCallDataProviderFactory.getApprovalCallData( spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" }, + amount = swapAmount.value.convertToSdkAmount(fromToken), + blockchain = Blockchain.fromId(fromToken.network.id.value), ) - val cardId = userWallet.scanResponse.card.cardId - val feeData = if (isDemoCardUseCase(cardId)) { - getDemoFees(fromTokenStatus.currency) - } else { - try { - transactionManager.getFee( - networkId = networkId, - amountToSend = createNativeAmountForDex("0", fromToken.network), - currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)), - destinationAddress = fromToken.getContractAddress(), - increaseBy = INCREASE_GAS_LIMIT_BY, - data = transactionData, - derivationPath = derivationPath, - ) - } catch (e: Exception) { - Timber.e(e, "Failed to get fee") - // it's impossible next steps without fee - return createSwapErrorWith( - fromToken = fromTokenStatus, - amount = swapAmount, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - expressDataError = ExpressDataError.UnknownError, - ) - } - } - val feeState = when (feeData) { - is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken) - is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken) + val feeData = try { + val extras = createTransactionExtrasUseCase( + callData = callData, + network = fromToken.network, + ).getOrNull() ?: error("unable to create extras") + + val fromAddress = requireNotNull( + fromTokenStatus.value.networkAddress?.defaultAddress?.value, + ) { "networkAddress cant be null" } + val transactionData = TransactionData.Uncompiled( + amount = createNativeAmountForDex("0", fromToken.network), + destinationAddress = fromToken.getContractAddress(), + fee = null, + sourceAddress = fromAddress, + extras = extras, + ) + getFeeUseCase( + transactionData = transactionData, + network = fromToken.network, + userWallet = userWallet, + ).getOrNull() ?: error("unable to calculate fee") + } catch (e: Exception) { + Timber.e(e, "Failed to get fee") + // it's impossible next steps without fee + return createSwapErrorWith( + fromToken = fromTokenStatus, + amount = swapAmount, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + ) } + + val feeState = feeData + .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + .toTxFeeState(fromToken, null) + val fee = when (feeState) { TxFeeState.Empty -> BigDecimal.ZERO is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue @@ -1668,7 +1440,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( spenderAddress = getTokenAddress(fromToken), requestApproveData = RequestApproveStateData( fee = feeState, - approveData = transactionData, fromTokenAmount = swapAmount, spenderAddress = spenderAddress, ), @@ -1679,120 +1450,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } - private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState( - fromToken: CryptoCurrency, - otherNativeFee: BigDecimal? = null, - ): TxFeeState { - val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO - val normalFeeValue = this.minFee.fee.value // in swap for normal use min fee - val normalFeeGas = this.minFee.gasLimit.toInt() - val priorityFeeValue = this.normalFee.fee.value // in swap for priority use normal fee - val priorityFeeGas = this.normalFee.gasLimit.toInt() - // region fees to use - val feesFiat = getFormattedFiatFees(fromToken, normalFeeValue, priorityFeeValue) - val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" } - val priorityFiatFee = requireNotNull(feesFiat.getOrNull(1)) { "feesFiat item 1 couldn't be null" } - val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( - amount = normalFeeValue, - decimals = minFee.fee.decimals, - ) - val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( - amount = priorityFeeValue, - decimals = normalFee.fee.decimals, - ) - // endregion - // region fees include otherNativeFee - val feesFiatWithNative = getFormattedFiatFees( - fromToken = fromToken, - normalFeeValue + otherNativeFeeValue, - priorityFeeValue + otherNativeFeeValue, - ) - val normalFiatFeeWithNative = - requireNotNull(feesFiatWithNative.getOrNull(0)) { "feesFiat item 0 couldn't be null" } - val priorityFiatFeeWithNative = - requireNotNull(feesFiatWithNative.getOrNull(1)) { "feesFiat item 1 couldn't be null" } - val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( - amount = normalFeeValue + otherNativeFeeValue, - decimals = minFee.fee.decimals, - ) - val priorityCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( - amount = priorityFeeValue + otherNativeFeeValue, - decimals = normalFee.fee.decimals, - ) - // endregion - return TxFeeState.MultipleFeeState( - normalFee = TxFee( - feeValue = normalFeeValue, - gasLimit = normalFeeGas, - feeFiatFormatted = normalFiatFee, - feeCryptoFormatted = normalCryptoFee, - feeIncludeOtherNativeFee = normalFeeValue + otherNativeFeeValue, - feeFiatFormattedWithNative = normalFiatFeeWithNative, - feeCryptoFormattedWithNative = normalCryptoFeeWithNative, - decimals = minFee.fee.decimals, - cryptoSymbol = minFee.fee.currencySymbol, - feeType = FeeType.NORMAL, - params = getSwapFeeParams(minFee), - ), - priorityFee = TxFee( - feeValue = priorityFeeValue, - gasLimit = priorityFeeGas, - feeFiatFormatted = priorityFiatFee, - feeCryptoFormatted = priorityCryptoFee, - feeIncludeOtherNativeFee = priorityFeeValue + otherNativeFeeValue, - feeFiatFormattedWithNative = priorityFiatFeeWithNative, - feeCryptoFormattedWithNative = priorityCryptoFeeWithNative, - decimals = normalFee.fee.decimals, - cryptoSymbol = normalFee.fee.currencySymbol, - feeType = FeeType.PRIORITY, - params = getSwapFeeParams(normalFee), - ), - ) - } - - private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState( - fromToken: CryptoCurrency, - otherNativeFee: BigDecimal? = null, - ): TxFeeState { - val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO - val normalFeeValue = this.singleFee.fee.value - val normalFeeGas = this.singleFee.gasLimit.toInt() - val feesFiat = getFormattedFiatFees(fromToken, normalFeeValue) - val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" } - val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( - amount = normalFeeValue, - decimals = singleFee.fee.decimals, - ) - // region fees include otherNativeFee - val feesFiatWithNative = getFormattedFiatFees( - fromToken = fromToken, - normalFeeValue + otherNativeFeeValue, - normalFeeValue + otherNativeFeeValue, - ) - val normalFiatFeeWithNative = - requireNotNull(feesFiatWithNative.getOrNull(0)) { "feesFiat item 0 couldn't be null" } - val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( - amount = normalFeeValue + otherNativeFeeValue, - decimals = singleFee.fee.decimals, - ) - // endregion - return TxFeeState.SingleFeeState( - fee = TxFee( - feeValue = normalFeeValue, - gasLimit = normalFeeGas, - feeFiatFormatted = normalFiatFee, - feeCryptoFormatted = normalCryptoFee, - feeIncludeOtherNativeFee = normalFeeValue + otherNativeFeeValue, - feeFiatFormattedWithNative = normalFiatFeeWithNative, - feeCryptoFormattedWithNative = normalCryptoFeeWithNative, - decimals = singleFee.fee.decimals, - cryptoSymbol = singleFee.fee.currencySymbol, - feeType = FeeType.NORMAL, - params = getSwapFeeParams(singleFee), - ), - ) - } - @Suppress("LongMethod") private suspend fun TransactionFee.toTxFeeState( fromToken: CryptoCurrency, @@ -1801,20 +1458,18 @@ internal class SwapInteractorImpl @AssistedInject constructor( val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO return when (this) { is TransactionFee.Choosable -> { - val normalFee = this.normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) - val priorityFee = this.priority.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) - val feeNormal = normalFee.amount.value ?: BigDecimal.ZERO - val feePriority = priorityFee.amount.value ?: BigDecimal.ZERO + val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO + val feePriority = this.priority.amount.value ?: BigDecimal.ZERO val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] val priorityFiatValue = getFormattedFiatFees(fromToken, feePriority)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, - decimals = normalFee.amount.decimals, + decimals = this.normal.amount.decimals, ) val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feePriority, - decimals = priorityFee.amount.decimals, + decimals = this.priority.amount.decimals, ) // region otherNativeFee @@ -1825,39 +1480,35 @@ internal class SwapInteractorImpl @AssistedInject constructor( val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeWithOtherNative, - decimals = normalFee.amount.decimals, + decimals = this.normal.amount.decimals, ) val priorityCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = priorityFeeWithOtherNative, - decimals = priorityFee.amount.decimals, + decimals = this.priority.amount.decimals, ) // endregion TxFeeState.MultipleFeeState( normalFee = TxFee( feeValue = feeNormal, - gasLimit = normalFee.getGasLimit(), feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, feeIncludeOtherNativeFee = normalFeeWithOtherNative, feeFiatFormattedWithNative = normalFiatValueWithNative, feeCryptoFormattedWithNative = normalCryptoFeeWithNative, - decimals = normalFee.amount.decimals, - cryptoSymbol = normalFee.amount.currencySymbol, + cryptoSymbol = this.normal.amount.currencySymbol, feeType = FeeType.NORMAL, - params = getSwapFeeParams(normalFee), + fee = this.normal, ), priorityFee = TxFee( feeValue = feePriority, - gasLimit = priorityFee.getGasLimit(), feeFiatFormatted = priorityFiatValue, feeCryptoFormatted = priorityCryptoFee, feeIncludeOtherNativeFee = priorityFeeWithOtherNative, feeFiatFormattedWithNative = priorityFiatValueWithNative, feeCryptoFormattedWithNative = priorityCryptoFeeWithNative, - decimals = priorityFee.amount.decimals, - cryptoSymbol = priorityFee.amount.currencySymbol, + cryptoSymbol = this.priority.amount.currencySymbol, feeType = FeeType.PRIORITY, - params = getSwapFeeParams(priorityFee), + fee = this.priority, ), ) } @@ -1880,59 +1531,20 @@ internal class SwapInteractorImpl @AssistedInject constructor( TxFeeState.SingleFeeState( fee = TxFee( feeValue = this.normal.amount.value ?: BigDecimal.ZERO, - gasLimit = this.normal.getGasLimit(), feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, feeIncludeOtherNativeFee = normalFeeWithOtherNative, feeFiatFormattedWithNative = normalFiatValueWithNative, feeCryptoFormattedWithNative = normalCryptoFeeWithNative, - decimals = normal.amount.decimals, cryptoSymbol = normal.amount.currencySymbol, feeType = FeeType.NORMAL, - params = getSwapFeeParams(normal), + fee = this.normal, ), ) } } } - private fun getSwapFeeParams(fee: Fee): TxFee.Params? = when (fee) { - is Fee.Filecoin -> TxFee.Params.Filecoin( - gasPremium = fee.gasPremium, - ) - is Fee.Sui -> TxFee.Params.Sui( - gasPrice = fee.gasPrice, - gasBudget = fee.gasBudget, - ) - is Fee.Hedera -> TxFee.Params.Hedera( - additionalHBARFee = fee.additionalHBARFee, - ) - is Fee.Aptos, - is Fee.Bitcoin, - is Fee.CardanoToken, - is Fee.Common, - is Fee.Ethereum.EIP1559, - is Fee.Ethereum.Legacy, - is Fee.Kaspa, - is Fee.Tron, - is Fee.VeChain, - is Fee.Alephium, - -> null - } - - private fun getSwapFeeParams(proxyFee: ProxyFee): TxFee.Params? = when (proxyFee) { - is ProxyFee.Filecoin -> TxFee.Params.Filecoin( - gasPremium = proxyFee.gasPremium, - ) - is ProxyFee.Sui -> TxFee.Params.Sui( - gasPrice = proxyFee.gasPrice, - gasBudget = proxyFee.gasBudget, - ) - is ProxyFee.CardanoToken, - is ProxyFee.Common, - -> null - } - private fun createNativeAmountForDex(txValueAmount: String, network: Network): Amount { val nativeDecimals = Blockchain.fromNetworkId(network.backendId)?.decimals() ?: error("Blockchain not found") @@ -1946,7 +1558,50 @@ internal class SwapInteractorImpl @AssistedInject constructor( } /** - * Workaround to increase gas limit cause we calculate fee for random address + * We need to increase gasLimit for Ethereum fees for 2 cases + * + * DEX: for dex calculated gasLimit for given data might be changed when transaction processing + * for that case dex providers recommend to increase gasLimit for few percents to ensure transaction completes + * + * CEX: for that case we calculate fee for random generated address and gasLimit might be different for it + * and result address to send. That's why we should increase gasLimit a little + * + */ + private fun TransactionFee.patchTransactionFeeForSwap(increaseBy: Int): TransactionFee { + return when (this) { + is TransactionFee.Choosable -> { + this.copy( + minimum = this.minimum.increaseEthGasLimitInNeeded(increaseBy), + normal = this.normal.increaseEthGasLimitInNeeded(increaseBy), + priority = this.priority.increaseEthGasLimitInNeeded(increaseBy), + ) + } + is TransactionFee.Single -> this.copy(normal = this.normal.increaseEthGasLimitInNeeded(increaseBy)) + } + } + + private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee { + return when (this) { + is Fee.Ethereum.EIP1559, + is Fee.Ethereum.Legacy, + -> this.increaseGasLimitBy(increaseBy) + is Fee.Alephium, + is Fee.Aptos, + is Fee.Bitcoin, + is Fee.CardanoToken, + is Fee.Common, + is Fee.Filecoin, + is Fee.Hedera, + is Fee.Kaspa, + is Fee.Sui, + is Fee.Tron, + is Fee.VeChain, + -> this + } + } + + /** + * Increase gasLimit for Fee.Ethereum */ private fun Fee.increaseGasLimitBy(percentage: Int): Fee { if (this !is Fee.Ethereum) return this @@ -1969,13 +1624,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( return cryptoCurrencyStatuses.value.pendingTransactions.any { it.isOutgoing } } - private fun Fee.getGasLimit(): Int { + private fun Fee.getGasLimit(): Int? { return when (this) { is Fee.Ethereum -> gasLimit.toInt() is Fee.VeChain -> gasLimit.toInt() is Fee.Aptos -> gasLimit.toInt() is Fee.Filecoin -> gasLimit.toInt() - else -> 0 + else -> null } } @@ -2136,23 +1791,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( return PriceImpact.Value(value) } - private suspend fun getApproveData( - networkId: String, - derivationPath: String?, - fromToken: CryptoCurrency, - swapAmount: SwapAmount? = null, - spenderAddress: String, - ): String { - return repository.getApproveData( - userWalletId = userWalletId, - networkId = networkId, - derivationPath = derivationPath, - currency = fromToken, - amount = swapAmount?.value, - spenderAddress = spenderAddress, - ) - } - private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map { val set = ids.mapNotNull { it.rawCurrencyId } .toSet() @@ -2166,42 +1804,20 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun Set.getQuotesOrEmpty(refresh: Boolean): Set { return try { - quotesRepository.getQuotesSync(this, refresh) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = this).orEmpty() + } else { + quotesRepository.getQuotesSync(this, refresh) + } } catch (t: Throwable) { emptySet() } } - private fun getDemoFees(cryptoCurrency: CryptoCurrency): ProxyFees.MultipleFees { - val demoFee = ProxyAmount( - currencySymbol = cryptoCurrency.symbol, - value = minDemoFee, - decimals = cryptoCurrency.decimals, - ) - return ProxyFees.MultipleFees( - minFee = ProxyFee.Common( - gasLimit = 1.toBigInteger(), - fee = demoFee, - ), - normalFee = ProxyFee.Common( - gasLimit = 1.toBigInteger(), - fee = demoFee.copy(value = normalDemoFee), - ), - priorityFee = ProxyFee.Common( - gasLimit = 1.toBigInteger(), - fee = demoFee.copy(value = priorityDemoFee), - ), - ) - } - companion object { - private const val INCREASE_GAS_LIMIT_BY = 112 // 12% + private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12% private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% private const val INFINITY_SYMBOL = "∞" - - private val minDemoFee = "0.0001".toBigDecimal() - private val normalDemoFee = "0.0002".toBigDecimal() - private val priorityDemoFee = "0.0003".toBigDecimal() } @AssistedFactory diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index 812499d47f..d0d49d2adf 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -1,7 +1,7 @@ package com.tangem.feature.swap.domain -import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel @@ -18,9 +18,8 @@ interface SwapTransactionRepository { ) suspend fun getTransactions( - userWalletId: UserWalletId, + userWallet: UserWallet, cryptoCurrencyId: CryptoCurrency.ID, - scanResponse: ScanResponse, ): Flow?> suspend fun removeTransaction( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 13135efa71..610aa556a8 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -3,7 +3,6 @@ package com.tangem.feature.swap.domain.di import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations import com.tangem.feature.swap.domain.* -import com.tangem.lib.crypto.TransactionManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,14 +24,6 @@ internal class SwapDomainModule { return factory } - @Provides - @Singleton - fun provideBlockchainInteractor(transactionManager: TransactionManager): BlockchainInteractor { - return DefaultBlockchainInteractor( - transactionManager = transactionManager, - ) - } - @Provides @Singleton fun providesGetCryptoCurrencyStatusUseCase( diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index a07e4897ad..74a34ecd45 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.txhistory) + implementation(projects.domain.txhistory.models) /** Feature modules */ implementation(projects.features.swap.domain) 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 ea78698ede..38779307ed 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 @@ -35,12 +35,12 @@ import com.tangem.domain.tokens.* import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.Network +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.feature.swap.analytics.SwapEvents -import com.tangem.feature.swap.domain.BlockchainInteractor import com.tangem.feature.swap.domain.SwapInteractor import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.ExpressException @@ -80,7 +80,6 @@ typealias SuccessLoadedSwapData = Map internal class SwapModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val blockchainInteractor: BlockchainInteractor, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsErrorEventHandler: AnalyticsErrorHandler, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, @@ -93,6 +92,7 @@ internal class SwapModel @Inject constructor( private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, @@ -135,7 +135,7 @@ internal class SwapModel @Inject constructor( stateBuilder.createInitialLoadingState( initialCurrencyFrom = initialCurrencyFrom, initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = blockchainInteractor.getBlockchainInfo(initialCurrencyFrom.network.backendId), + fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), ), ) private set @@ -261,9 +261,7 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.createInitialLoadingState( initialCurrencyFrom = initialCurrencyFrom, initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = blockchainInteractor.getBlockchainInfo( - initialCurrencyFrom.network.backendId, - ), + fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), ) initTokens(isReverseFromTo) } @@ -603,10 +601,14 @@ internal class SwapModel @Inject constructor( when (it) { is SwapTransactionState.TxSent -> { sendSuccessSwapEvent(fromCurrency.currency, fee.feeType) - val url = blockchainInteractor.getExplorerTransactionLink( - networkId = fromCurrency.currency.network.backendId, + val url = getExplorerTransactionUrlUseCase( txHash = it.txHash, - ) + networkId = fromCurrency.currency.network.id, + ).getOrElse { + Timber.i("tx hash explore not supported") + "" + } + updateWalletBalance() uiState = stateBuilder.createSuccessState( uiState = uiState, @@ -1356,6 +1358,13 @@ internal class SwapModel @Inject constructor( } } + private fun CryptoCurrency.getNetworkInfo(): NetworkInfo { + return NetworkInfo( + name = this.network.name, + blockchainId = this.network.id.value, + ) + } + private companion object { const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index e725d7833a..b80dda2c34 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -86,6 +86,7 @@ dependencies { implementation(projects.domain.onramp.models) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.quotes) /** Temp dependency to swap domain */ implementation(projects.features.swap.domain) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt index db52ac752e..b9ba7e893b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt @@ -47,13 +47,13 @@ internal class DefaultTokenDetailsRouter @Inject constructor( ) } - override fun openOnrampSuccess(externalTxId: String) { + override fun openOnrampSuccess(txId: String) { // finish current onramp flow and show onramp success screen val replaceOnrampScreens = router.stack .filterNot { it is AppRoute.Onramp } .toMutableList() - replaceOnrampScreens.add(AppRoute.OnrampSuccess(externalTxId)) + replaceOnrampScreens.add(AppRoute.OnrampSuccess(txId)) router.replaceAll(*replaceOnrampScreens.toTypedArray()) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt index c2555aa070..3a26bd9f59 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt @@ -18,5 +18,5 @@ internal interface InnerTokenDetailsRouter { fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, yieldId: String) - fun openOnrampSuccess(externalTxId: String) + fun openOnrampSuccess(txId: String) } \ 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 6ca825bee9..86c1687693 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 @@ -6,6 +6,8 @@ import arrow.core.getOrElse import com.tangem.blockchain.common.address.AddressType import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.bottomsheet.receive.AddressModel +import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -17,8 +19,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.common.ui.bottomsheet.receive.AddressModel -import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference @@ -166,7 +166,7 @@ internal class TokenDetailsModel @Inject constructor( appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, currentStateProvider = Provider { uiState.value }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - userWalletId = userWalletId, + userWallet = userWallet, cryptoCurrency = cryptoCurrency, ) } @@ -195,9 +195,9 @@ internal class TokenDetailsModel @Inject constructor( handleBalanceHiding() } - fun onBuyCurrencyDeepLink(externalTxId: String) { + fun onBuyCurrencyDeepLink(txId: String) { if (onrampFeatureToggles.isFeatureEnabled) { - router.openOnrampSuccess(externalTxId) + router.openOnrampSuccess(txId) } else { val currency = cryptoCurrencyStatus?.currency ?: return analyticsEventsHandler.send(TokenScreenAnalyticsEvent.Bought(currency.symbol)) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt index c585e6e3cd..93ae9d4370 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt @@ -1,9 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory -import com.tangem.common.ui.expressStatus.state.ExpressLinkUM -import com.tangem.common.ui.expressStatus.state.ExpressStatusItemState -import com.tangem.common.ui.expressStatus.state.ExpressStatusItemUM -import com.tangem.common.ui.expressStatus.state.ExpressStatusUM +import com.tangem.common.ui.expressStatus.state.* import com.tangem.common.ui.notifications.ExpressNotificationsUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -23,9 +20,6 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM -import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM -import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.features.tokendetails.impl.R import com.tangem.utils.Provider @@ -129,8 +123,12 @@ internal class TokenDetailsOnrampTransactionStateConverter( private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM { return when (status) { - OnrampStatus.Status.Verifying -> ExpressTransactionStateIconUM.Warning - OnrampStatus.Status.Failed -> ExpressTransactionStateIconUM.Error + OnrampStatus.Status.RefundInProgress, + OnrampStatus.Status.Verifying, + -> ExpressTransactionStateIconUM.Warning + OnrampStatus.Status.Refunded, + OnrampStatus.Status.Failed, + -> ExpressTransactionStateIconUM.Error else -> ExpressTransactionStateIconUM.None } } @@ -213,6 +211,10 @@ internal class TokenDetailsOnrampTransactionStateConverter( wrappedList(cryptoCurrency.name), ) } + this == OnrampStatus.Status.RefundInProgress || + this == OnrampStatus.Status.Refunded -> { + resourceReference(R.string.express_exchange_status_failed) + } else -> { resourceReference(R.string.express_status_bought, wrappedList(cryptoCurrency.name)) } @@ -234,6 +236,12 @@ internal class TokenDetailsOnrampTransactionStateConverter( wrappedList(cryptoCurrency.name), ) } + this == OnrampStatus.Status.RefundInProgress -> { + resourceReference(R.string.express_exchange_status_refunding) + } + this == OnrampStatus.Status.Refunded -> { + resourceReference(R.string.express_exchange_status_refunded) + } else -> { resourceReference( R.string.express_exchange_status_sent, @@ -241,7 +249,11 @@ internal class TokenDetailsOnrampTransactionStateConverter( ) } }, - state = getStatusState(OnrampStatus.Status.Sending), + state = when { + this == OnrampStatus.Status.RefundInProgress -> ExpressStatusItemState.Active + this == OnrampStatus.Status.Refunded -> ExpressStatusItemState.Error + else -> getStatusState(OnrampStatus.Status.Sending) + }, ) private fun getStatusLink(status: OnrampStatus.Status, externalTxUrl: String?): ExpressLinkUM { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index f9d9aed682..1fbea94122 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -5,20 +5,21 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.swaptx.ExpressAnalyticsStatus import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.quotes.QuotesRepositoryV2 import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.tokens.repository.QuotesRepository -import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.utils.Provider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -27,7 +28,6 @@ import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map @Suppress("LongParameterList") @@ -35,14 +35,15 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val swapTransactionRepository: SwapTransactionRepository, private val swapRepository: SwapRepository, private val quotesRepository: QuotesRepository, - private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val quotesRepositoryV2: QuotesRepositoryV2, + private val tokensFeatureToggles: TokensFeatureToggles, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, @Assisted private val clickIntents: TokenDetailsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, - @Assisted private val userWalletId: UserWalletId, + @Assisted private val userWallet: UserWallet, @Assisted private val cryptoCurrency: CryptoCurrency, ) { @@ -56,14 +57,9 @@ internal class ExchangeStatusFactory @AssistedInject constructor( } suspend operator fun invoke(): Flow> { - val selectedWallet = getSelectedWalletSyncUseCase().fold( - ifLeft = { return emptyFlow() }, - ifRight = { it }, - ) return swapTransactionRepository.getTransactions( - userWalletId = userWalletId, + userWallet = userWallet, cryptoCurrencyId = cryptoCurrency.id, - scanResponse = selectedWallet.scanResponse, ).conflate() .map { savedTransactions -> val quotes = savedTransactions @@ -87,7 +83,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( val shouldDispose = selectedTx.activeStatus?.isAutoDisposable == true || isForceDispose if (shouldDispose) { swapTransactionRepository.removeTransaction( - userWalletId = userWalletId, + userWalletId = userWallet.walletId, fromCryptoCurrency = selectedTx.fromCryptoCurrency, toCryptoCurrency = selectedTx.toCryptoCurrency, txId = selectedTx.info.txId, @@ -113,7 +109,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( } private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { - return swapRepository.getExchangeStatus(txId) + return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId) .fold( ifLeft = { null }, ifRight = { statusModel -> @@ -153,7 +149,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( val refundContractAddress = status.refundContractAddress if (refundNetwork != null && refundContractAddress != null) { return addCryptoCurrenciesUseCase( - userWalletId = userWalletId, + userWalletId = userWallet.walletId, contractAddress = refundContractAddress, networkId = refundNetwork, ).getOrNull() @@ -198,7 +194,12 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private suspend fun Set.getQuotesOrEmpty(refresh: Boolean): Set { return try { val rawIds = mapNotNull { it.rawCurrencyId }.toSet() - quotesRepository.getQuotesSync(rawIds, refresh) + + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = rawIds).orEmpty() + } else { + quotesRepository.getQuotesSync(rawIds, refresh) + } } catch (t: Throwable) { emptySet() } @@ -210,7 +211,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( clickIntents: TokenDetailsClickIntents, appCurrencyProvider: Provider, currentStateProvider: Provider, - userWalletId: UserWalletId, + userWallet: UserWallet, cryptoCurrency: CryptoCurrency, ): ExchangeStatusFactory } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt index 77c9dc1404..7d8902e653 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt @@ -10,12 +10,12 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted @@ -36,7 +36,7 @@ internal class ExpressStatusFactory @AssistedInject constructor( @Assisted private val clickIntents: TokenDetailsClickIntents, @Assisted private val cryptoCurrency: CryptoCurrency, @Assisted appCurrencyProvider: Provider, - @Assisted userWalletId: UserWalletId, + @Assisted userWallet: UserWallet, @Assisted cryptoCurrencyStatusProvider: Provider, private val dispatchers: CoroutineDispatcherProvider, private val analyticsEventsHandler: AnalyticsEventHandler, @@ -49,7 +49,7 @@ internal class ExpressStatusFactory @AssistedInject constructor( clickIntents = clickIntents, appCurrencyProvider = appCurrencyProvider, currentStateProvider = currentStateProvider, - userWalletId = userWalletId, + userWallet = userWallet, cryptoCurrency = cryptoCurrency, ) } @@ -61,7 +61,7 @@ internal class ExpressStatusFactory @AssistedInject constructor( appCurrencyProvider = appCurrencyProvider, clickIntents = clickIntents, cryptoCurrency = cryptoCurrency, - userWalletId = userWalletId, + userWallet = userWallet, ) } @@ -210,7 +210,7 @@ internal class ExpressStatusFactory @AssistedInject constructor( clickIntents: TokenDetailsClickIntents, appCurrencyProvider: Provider, currentStateProvider: Provider, - userWalletId: UserWalletId, + userWallet: UserWallet, cryptoCurrency: CryptoCurrency, cryptoCurrencyStatusProvider: Provider, ): ExpressStatusFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt index bcf2f0b1b2..cea16cfd27 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt @@ -14,8 +14,8 @@ import com.tangem.domain.onramp.model.OnrampStatus.Status.* import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsOnrampTransactionStateConverter import com.tangem.utils.Provider @@ -39,7 +39,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( @Assisted private val appCurrencyProvider: Provider, @Assisted private val clickIntents: TokenDetailsClickIntents, @Assisted private val cryptoCurrency: CryptoCurrency, - @Assisted private val userWalletId: UserWalletId, + @Assisted private val userWallet: UserWallet, ) { private val onrampTransactionStateConverter by lazy(LazyThreadSafetyMode.NONE) { @@ -54,7 +54,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( operator fun invoke(): Flow> { return getOnrampTransactionsUseCase( - userWalletId = userWalletId, + userWalletId = userWallet.walletId, cryptoCurrencyId = cryptoCurrency.id, ).map { maybeTransaction -> maybeTransaction.fold( @@ -82,7 +82,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( return if (onrampTx.activeStatus.isTerminal) { onrampTx } else { - getOnrampStatusUseCase(onrampTx.info.txId).fold( + getOnrampStatusUseCase(userWallet = userWallet, onrampTx.info.txId).fold( ifLeft = { Timber.e("Couldn't update onramp status. $it") onrampTx @@ -134,10 +134,12 @@ internal class OnrampStatusFactory @AssistedInject constructor( PaymentProcessing, Paid, Sending, + RefundInProgress, -> ExpressAnalyticsStatus.InProgress Verifying -> ExpressAnalyticsStatus.KYC Failed -> ExpressAnalyticsStatus.Fail Finished -> ExpressAnalyticsStatus.Done + Refunded -> ExpressAnalyticsStatus.Refunded null -> null } } @@ -150,7 +152,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( appCurrencyProvider: Provider, clickIntents: TokenDetailsClickIntents, cryptoCurrency: CryptoCurrency, - userWalletId: UserWalletId, + userWallet: UserWallet, ): OnrampStatusFactory } } \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index da4e888dfe..b2a3af1b04 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation(projects.domain.onramp.models) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.quotes) implementation(projects.domain.settings) implementation(projects.domain.staking) implementation(projects.domain.staking.models) 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 418cb39368..2ae1a8de10 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 @@ -11,12 +11,11 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.util.cardTypesResolver -import com.tangem.domain.nft.FetchNFTCollectionsUseCase import com.tangem.domain.settings.* import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents @@ -44,7 +43,6 @@ import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull import com.tangem.utils.Provider import com.tangem.utils.coroutines.* -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -79,8 +77,6 @@ internal class WalletModel @Inject constructor( private val onrampStatusFactory: OnrampStatusFactory, private val biometryFeatureToggles: BiometryFeatureToggles, private val analyticsEventsHandler: AnalyticsEventHandler, - private val fetchNFTCollectionsUseCase: FetchNFTCollectionsUseCase, - private val walletsRepository: WalletsRepository, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, @@ -92,7 +88,6 @@ internal class WalletModel @Inject constructor( private val walletsUpdateJobHolder = JobHolder() private val refreshWalletJobHolder = JobHolder() private val expressStatusJobHolder = JobHolder() - private val walletsNFTsUpdateJobHolder = JobHolder() private var needToRefreshWallet = false private var expressTxStatusTaskScheduler = SingleTaskScheduler() @@ -107,10 +102,7 @@ internal class WalletModel @Inject constructor( subscribeToUserWalletsUpdates() subscribeOnBalanceHiding() subscribeOnSelectedWalletFlow() - subscribeToScreenBackgroundState() subscribeOnPushNotificationsPermission() - subscribeOnExpressTransactionsUpdates() - subscribeOnNFTUpdates() clickIntents.initialize(innerWalletRouter, modelScope) } @@ -223,6 +215,8 @@ internal class WalletModel @Inject constructor( } walletDeepLinksHandler.registerForWallet(scope = modelScope, userWallet = selectedWallet) + subscribeOnExpressTransactionsUpdates(selectedWallet) + subscribeToScreenBackgroundState(selectedWallet) } .flowOn(dispatchers.main) .launchIn(modelScope) @@ -231,7 +225,7 @@ internal class WalletModel @Inject constructor( // We need to update the current wallet quotes if the application was in the background for more than 10 seconds // and then returned to the foreground - private fun subscribeToScreenBackgroundState() { + private fun subscribeToScreenBackgroundState(userWallet: UserWallet) { screenLifecycleProvider.isBackgroundState .onEach { isBackground -> expressTxStatusTaskScheduler.cancelTask() @@ -241,53 +235,31 @@ internal class WalletModel @Inject constructor( isBackground -> needToRefreshTimer() needToRefreshWallet && !isBackground -> { triggerRefreshWalletQuotes() - subscribeOnExpressTransactionsUpdates() + subscribeOnExpressTransactionsUpdates(userWallet) } - !isBackground -> subscribeOnExpressTransactionsUpdates() + !isBackground -> subscribeOnExpressTransactionsUpdates(userWallet) } } .launchIn(modelScope) + .saveIn(expressStatusJobHolder) } - private fun subscribeOnExpressTransactionsUpdates() { - modelScope.launch(dispatchers.main) { - expressTxStatusTaskScheduler.cancelTask() - expressTxStatusTaskScheduler.scheduleTask( - modelScope, - PeriodicTask( - isDelayFirst = false, - delay = EXPRESS_STATUS_UPDATE_DELAY, - task = { - runCatching { onrampStatusFactory.updateOnrmapTransactionStatuses() } - }, - onSuccess = { /* no-op */ }, - onError = { /* no-op */ }, - ), - ) - }.saveIn(expressStatusJobHolder) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private fun subscribeOnNFTUpdates() { - getWalletsUseCase() - .conflate() - .distinctUntilChanged() - .flatMapLatest { wallets -> - wallets - .map { wallet -> - walletsRepository.nftEnabledStatus(wallet.walletId) - .distinctUntilChanged() - .onEach { nftEnabled -> - if (nftEnabled) { - fetchNFTCollectionsUseCase.invoke(wallet.walletId) - } - } + private fun subscribeOnExpressTransactionsUpdates(userWallet: UserWallet) { + expressTxStatusTaskScheduler.cancelTask() + expressTxStatusTaskScheduler.scheduleTask( + modelScope, + PeriodicTask( + isDelayFirst = false, + delay = EXPRESS_STATUS_UPDATE_DELAY, + task = { + runCatching { + onrampStatusFactory.updateOnrmapTransactionStatuses(userWallet) } - .merge() - } - .flowOn(dispatchers.main) - .launchIn(modelScope) - .saveIn(walletsNFTsUpdateJobHolder) + }, + onSuccess = { /* no-op */ }, + onError = { /* no-op */ }, + ), + ) } private fun needToRefreshTimer() { 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 8fe27fb45f..9aed66d0f3 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 @@ -1,9 +1,16 @@ package com.tangem.feature.wallet.child.wallet.model.intents 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.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.visa.GetVisaCurrencyUseCase import com.tangem.domain.visa.GetVisaTxDetailsUseCase +import com.tangem.domain.visa.model.VisaTxDetails +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.BalancesAndLimitsBottomSheetConverter import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.VisaTxDetailsBottomSheetConverter @@ -20,22 +27,30 @@ internal interface VisaWalletIntents { fun onVisaTransactionClick(id: String) fun onExploreClick(exploreUrl: String) + + fun onDisputeClick(txDetails: VisaTxDetails) } +@Suppress("LongParameterList") @ModelScoped internal class VisaWalletIntentsImplementor @Inject constructor( private val stateController: WalletStateController, private val eventSender: WalletEventSender, private val getVisaCurrencyUseCase: GetVisaCurrencyUseCase, private val getVisaTxDetailsUseCase: GetVisaTxDetailsUseCase, + private val getCardInfoUseCase: GetCardInfoUseCase, + private val getUserWalletsUseCase: GetWalletsUseCase, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, ) : BaseWalletClickIntents(), VisaWalletIntents { private val balancesAndLimitsBottomSheetConverter by lazy(mode = LazyThreadSafetyMode.NONE) { - BalancesAndLimitsBottomSheetConverter(eventSender) + BalancesAndLimitsBottomSheetConverter(eventSender, analyticsEventHandler) } override fun onBalancesAndLimitsClick() { + analyticsEventHandler.send(MainScreenAnalyticsEvent.LimitsClicked) modelScope.launch(dispatchers.main) { val userWalletId = stateController.getSelectedWalletId() val balancesAndLimits = getVisaCurrencyUseCase(userWalletId) @@ -76,6 +91,23 @@ internal class VisaWalletIntentsImplementor @Inject constructor( } override fun onExploreClick(exploreUrl: String) { + analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonExplore) router.openUrl(exploreUrl) } + + override fun onDisputeClick(txDetails: VisaTxDetails) { + modelScope.launch { + val userWalletId = stateController.getSelectedWalletId() + val userWallet = getUserWalletsUseCase.invokeSync() + .firstOrNull { it.walletId == userWalletId } ?: return@launch + val cardInfo = getCardInfoUseCase.invoke(userWallet.scanResponse).getOrNull() ?: return@launch + + sendFeedbackEmailUseCase( + FeedbackEmailType.Visa.Dispute( + cardInfo = cardInfo, + visaTxDetails = txDetails, + ), + ) + } + } } \ No newline at end of file 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 ee324b252e..e39efc204f 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 @@ -5,7 +5,6 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.exchange.RampStateManager -import com.tangem.domain.nft.FetchNFTCollectionsUseCase import com.tangem.domain.onramp.FetchHotCryptoUseCase import com.tangem.domain.settings.NeverToShowWalletsScrollPreview import com.tangem.domain.tokens.FetchCardTokenListUseCase @@ -15,7 +14,6 @@ import com.tangem.domain.tokens.FetchTokenListUseCase.RefreshMode import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.feature.wallet.presentation.router.InnerWalletRouter -import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase import com.tangem.feature.wallet.presentation.wallet.domain.unwrap import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController @@ -52,8 +50,6 @@ internal class WalletClickIntents @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val onrampFeatureToggles: OnrampFeatureToggles, private val fetchHotCryptoUseCase: FetchHotCryptoUseCase, - private val fetchNFTCollectionsUseCase: FetchNFTCollectionsUseCase, - private val isWalletNFTEnabledSyncUseCase: IsWalletNFTEnabledSyncUseCase, ) : BaseWalletClickIntents(), WalletCardClickIntents by walletCardClickIntentsImplementor, WalletWarningsClickIntents by warningsClickIntentsImplementer, @@ -142,10 +138,6 @@ internal class WalletClickIntents @Inject constructor( async { rampStateManager.fetchSellServiceData() }.let(::add) async { fetchHotCryptoUseCase() }.let(::add) - - if (isWalletNFTEnabledSyncUseCase.invoke(userWallet.walletId)) { - async { fetchNFTCollectionsUseCase(userWalletId = userWallet.walletId) }.let(::add) - } } .awaitAll() 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 79fd4f1a7f..e4b51cf352 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 @@ -2,7 +2,9 @@ package com.tangem.feature.wallet.child.wallet.model.intents import arrow.core.getOrElse import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.nft.analytics.NFTAnalyticsEvent import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase @@ -12,7 +14,6 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory import com.tangem.feature.wallet.presentation.wallet.domain.unwrap @@ -37,7 +38,7 @@ internal interface WalletContentClickIntents { fun onOrganizeTokensClick() - fun onOnrampSuccessClick(externalTxId: String) + fun onOnrampSuccessClick(txId: String) fun onDismissMarketsOnboarding() @@ -57,7 +58,7 @@ internal interface WalletContentClickIntents { fun onDisposeExpressStatus() - fun onNFTClick(userWalletId: UserWalletId) + fun onNFTClick(userWallet: UserWallet) } @Suppress("LongParameterList") @@ -75,6 +76,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val reduxStateHolder: ReduxStateHolder, private val walletEventSender: WalletEventSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : BaseWalletClickIntents(), WalletContentClickIntents { override fun onDetailsClick() { @@ -114,8 +116,8 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( router.openOrganizeTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) } - override fun onOnrampSuccessClick(externalTxId: String) { - router.openOnrampSuccessScreen(externalTxId = externalTxId) + override fun onOnrampSuccessClick(txId: String) { + router.openOnrampSuccessScreen(txId = txId) } override fun onDismissMarketsOnboarding() { @@ -239,7 +241,20 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( stateHolder.update(CloseBottomSheetTransformer(userWalletId)) } - override fun onNFTClick(userWalletId: UserWalletId) { - router.openNFTCollectionsScreen(userWalletId) + override fun onNFTClick(userWallet: UserWallet) { + val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return + val analyticsState = when (val nftState = selectedWallet.nftState) { + is WalletNFTItemUM.Content -> NFTAnalyticsEvent.NFTListScreenOpened.State.Full(nftState.assetsCount) + is WalletNFTItemUM.Empty -> NFTAnalyticsEvent.NFTListScreenOpened.State.Empty + is WalletNFTItemUM.Failed, + is WalletNFTItemUM.Hidden, + is WalletNFTItemUM.Loading, + -> null + } + analyticsState?.let { + analyticsEventHandler.send(NFTAnalyticsEvent.NFTListScreenOpened(analyticsState)) + } + + router.openNFT(userWallet) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 9068f9a7f1..be96aa27a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -15,6 +15,7 @@ import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetCo import com.tangem.common.ui.bottomsheet.receive.AddressModel import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList @@ -78,7 +79,7 @@ interface WalletCurrencyActionsClickIntents { unavailabilityReason: ScenarioUnavailabilityReason, ) - fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent? = null) fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) @@ -94,7 +95,7 @@ interface WalletCurrencyActionsClickIntents { fun onAnalyticsClick(cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onMultiWalletBuyClick(userWalletId: UserWalletId) + fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) fun onMultiWalletSellClick(userWalletId: UserWalletId) @@ -151,7 +152,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( appRouter.push(route) } - override fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent?) { val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send( @@ -166,6 +167,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( event = TokenReceiveAnalyticsEvent.ReceiveScreenOpened(cryptoCurrencyStatus.currency.symbol), ) + event?.let { analyticsEventHandler.send(it) } + stateHolder.showBottomSheet( createReceiveBottomSheetContent( currency = cryptoCurrencyStatus.currency, @@ -470,7 +473,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } - override fun onMultiWalletBuyClick(userWalletId: UserWalletId) { + override fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) { onMultiWalletActionClick( statusFlow = if (onrampFeatureToggles.isFeatureEnabled) { rampStateManager.getExpressInitializationStatus(userWalletId) @@ -478,7 +481,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( rampStateManager.getBuyInitializationStatus() }, route = AppRoute.BuyCrypto(userWalletId = userWalletId), - eventCreator = MainScreenAnalyticsEvent::ButtonBuy, + eventCreator = { MainScreenAnalyticsEvent.ButtonBuy(status = it, screenType = screenType) }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index f6305b826f..2b865d1f61 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -10,6 +10,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.redux.StateDialog import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.navigation.WalletRoute import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig @@ -67,13 +68,13 @@ internal class DefaultWalletRouter @Inject constructor( } } - override fun openOnrampSuccessScreen(externalTxId: String) { + override fun openOnrampSuccessScreen(txId: String) { // finish current onramp flow and show onramp success screen val replaceOnrampScreens = router.stack .filterNot { it is AppRoute.Onramp } .toMutableList() - replaceOnrampScreens.add(AppRoute.OnrampSuccess(externalTxId)) + replaceOnrampScreens.add(AppRoute.OnrampSuccess(txId)) router.replaceAll(*replaceOnrampScreens.toTypedArray()) } @@ -116,7 +117,12 @@ internal class DefaultWalletRouter @Inject constructor( reduxStateHolder.dispatchDialogShow(StateDialog.ScanFailsDialog(StateDialog.ScanFailsSource.MAIN, onTryAgain)) } - override fun openNFTCollectionsScreen(userWalletId: UserWalletId) { - router.push(AppRoute.NFTCollections(userWalletId)) + override fun openNFT(userWallet: UserWallet) { + router.push( + AppRoute.NFT( + userWalletId = userWallet.walletId, + walletName = userWallet.name, + ), + ) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 4e49fb9f70..393c930e9c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.navigation.WalletRoute import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig @@ -33,8 +34,8 @@ internal interface InnerWalletRouter { /** Open onboarding screen */ fun openOnboardingScreen(scanResponse: ScanResponse, continueBackup: Boolean = false) - /** Open onramp success screen for [externalTxId] */ - fun openOnrampSuccessScreen(externalTxId: String) + /** Open onramp success screen for [txId] */ + fun openOnrampSuccessScreen(txId: String) /** Open transaction history website by [url] */ fun openUrl(url: String) @@ -58,5 +59,5 @@ internal interface InnerWalletRouter { fun openScanFailedDialog(onTryAgain: () -> Unit) /** Open NFT collections screen */ - fun openNFTCollectionsScreen(userWalletId: UserWalletId) + fun openNFT(userWallet: UserWallet) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/OnrampStatusFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/OnrampStatusFactory.kt index aeb4c7ad66..9874d658bc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/OnrampStatusFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/OnrampStatusFactory.kt @@ -11,6 +11,7 @@ import com.tangem.domain.onramp.OnrampUpdateTransactionStatusUseCase import com.tangem.domain.onramp.model.OnrampStatus import com.tangem.domain.onramp.model.OnrampStatus.Status.* import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent +import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -40,22 +41,22 @@ internal class OnrampStatusFactory @Inject constructor( } } - suspend fun updateOnrmapTransactionStatuses() = withContext(dispatchers.io) { + suspend fun updateOnrmapTransactionStatuses(userWallet: UserWallet) = withContext(dispatchers.io) { val singleWalletState = stateHolder.getSelectedWallet() as? WalletState.SingleCurrency.Content ?: return@withContext singleWalletState.expressTxs.map { tx -> async { if (tx is ExpressTransactionStateUM.OnrampUM) { - updateOnrampTxStatus(tx) + updateOnrampTxStatus(userWallet, tx) } } }.awaitAll() } - private suspend fun updateOnrampTxStatus(onrampTx: ExpressTransactionStateUM.OnrampUM) { + private suspend fun updateOnrampTxStatus(userWallet: UserWallet, onrampTx: ExpressTransactionStateUM.OnrampUM) { if (!onrampTx.activeStatus.isTerminal) { - getOnrampStatusUseCase(onrampTx.info.txId).fold( + getOnrampStatusUseCase(userWallet = userWallet, onrampTx.info.txId).fold( ifLeft = { Timber.e("Couldn't update onramp status. $it") }, @@ -93,10 +94,12 @@ internal class OnrampStatusFactory @Inject constructor( PaymentProcessing, Paid, Sending, + RefundInProgress, -> ExpressAnalyticsStatus.InProgress Verifying -> ExpressAnalyticsStatus.KYC Failed -> ExpressAnalyticsStatus.Fail Finished -> ExpressAnalyticsStatus.Done + Refunded -> ExpressAnalyticsStatus.Refunded null -> null } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt index bf64d76838..a43a0ef835 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt @@ -6,6 +6,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class VisaTxDetailsBottomSheetConfig( val transaction: Transaction, val requests: ImmutableList, + val onDisputeClick: () -> Unit, ) : TangemBottomSheetConfigContent { data class Transaction( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt index 9c5fcb1479..b749ffccf3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt @@ -1,6 +1,5 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers -import com.tangem.domain.models.StatusSource import com.tangem.domain.nft.models.* import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNFTItemUM @@ -16,14 +15,8 @@ internal class SetNFTCollectionsTransformer( override fun transform(prevState: WalletState): WalletState = when (prevState) { is WalletState.MultiCurrency.Content -> prevState.copy( nftState = when { - nftCollections.allCollectionsFailed() -> - WalletNFTItemUM.Failed - nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() -> - WalletNFTItemUM.Failed - nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> + nftCollections.allLoadedCollectionsEmpty() -> WalletNFTItemUM.Empty(onItemClick) - !nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> - WalletNFTItemUM.Loading else -> createContentNFTItemUM(onItemClick) }, ) @@ -41,10 +34,6 @@ internal class SetNFTCollectionsTransformer( .map { it.content } .filterIsInstance() - val isFlickering = collectionsContent - .map { it.source } - .any { it == StatusSource.CACHE } - val collections = collectionsContent .mapNotNull { it.collections } .flatten() @@ -65,7 +54,7 @@ internal class SetNFTCollectionsTransformer( collectionsCount = collections.size, assetsCount = collections .sumOf { it.count }, - isFlickering = isFlickering, + isFlickering = false, onItemClick = onItemClick, ) } 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 1d65f129ae..1dc3f95565 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 @@ -2,6 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import arrow.core.Either import arrow.core.getOrElse +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent.Companion.VISA_TYPE import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat @@ -144,17 +146,23 @@ internal class SetVisaInfoTransformer( ), ) - return return persistentListOf( + return persistentListOf( WalletManageButton.Receive( enabled = true, dimContent = false, - onClick = { clickIntents.onReceiveClick(cryptoCurrencyStatus = cryptoCurrencyStatus) }, - onLongClick = { clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus = cryptoCurrencyStatus) }, + onClick = { + clickIntents.onReceiveClick(cryptoCurrencyStatus, MainScreenAnalyticsEvent.ButtonReceive) + }, + onLongClick = { + clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus) + }, ), WalletManageButton.Buy( enabled = true, dimContent = false, - onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId) }, + onClick = { + clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId, screenType = VISA_TYPE) + }, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt index ffb4722637..eb2e240d23 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt @@ -1,5 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.DateTimeFormatters @@ -13,6 +15,7 @@ import java.math.BigDecimal internal class BalancesAndLimitsBottomSheetConverter( private val eventSender: WalletEventSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Converter { override fun convert(value: VisaCurrency): BalancesAndLimitsBottomSheetConfig { @@ -28,23 +31,25 @@ internal class BalancesAndLimitsBottomSheetConverter( blockedBalance = value.balances.blocked.let(::formatAmount), debit = value.balances.debt.let(::formatAmount), amlVerified = value.balances.verified.let(::formatAmount), - onInfoClick = this::showBalanceInfo, + onInfoClick = this::balanceInfoOnClick, ), limit = BalancesAndLimitsBottomSheetConfig.Limit( availableBy = DateTimeFormatters.formatDate(date = value.limits.expirationDate), total = otpLimit, other = noOtpLimit, singleTransaction = value.limits.singleTransaction.let(::formatAmount), - onInfoClick = { showLimitInfo(otpLimit, noOtpLimit) }, + onInfoClick = { limitInfoOnClick(otpLimit, noOtpLimit) }, ), ) } - private fun showBalanceInfo() { + private fun balanceInfoOnClick() { + analyticsEventHandler.send(MainScreenAnalyticsEvent.NoticeBalancesInfo) eventSender.send(WalletEvent.ShowAlert(WalletAlertState.VisaBalancesInfo)) } - private fun showLimitInfo(totalLimit: String, otherLimit: String) { + private fun limitInfoOnClick(totalLimit: String, otherLimit: String) { + analyticsEventHandler.send(MainScreenAnalyticsEvent.NoticeLimitsInfo) eventSender.send(WalletEvent.ShowAlert(WalletAlertState.VisaLimitsInfo(totalLimit, otherLimit))) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt index c506948924..ce57d2ac3a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt @@ -1,9 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter -import com.tangem.common.ui.expressStatus.state.ExpressLinkUM -import com.tangem.common.ui.expressStatus.state.ExpressStatusItemState -import com.tangem.common.ui.expressStatus.state.ExpressStatusItemUM -import com.tangem.common.ui.expressStatus.state.ExpressStatusUM +import com.tangem.common.ui.expressStatus.state.* import com.tangem.common.ui.notifications.ExpressNotificationsUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -22,11 +19,8 @@ import com.tangem.domain.onramp.model.OnrampStatus import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.feature.wallet.impl.R -import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM -import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM -import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents +import com.tangem.feature.wallet.impl.R import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf @@ -142,8 +136,11 @@ internal class SingleWalletOnrampTransactionConverter( private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM { return when (status) { - OnrampStatus.Status.Verifying -> ExpressTransactionStateIconUM.Warning - OnrampStatus.Status.Failed -> ExpressTransactionStateIconUM.Error + OnrampStatus.Status.Verifying, + -> ExpressTransactionStateIconUM.Warning + OnrampStatus.Status.Refunded, + OnrampStatus.Status.Failed, + -> ExpressTransactionStateIconUM.Error else -> ExpressTransactionStateIconUM.None } } @@ -226,6 +223,10 @@ internal class SingleWalletOnrampTransactionConverter( wrappedList(currency.name), ) } + this == OnrampStatus.Status.RefundInProgress || + this == OnrampStatus.Status.Refunded -> { + resourceReference(R.string.express_exchange_status_failed) + } else -> { resourceReference(R.string.express_status_bought, wrappedList(currency.name)) } @@ -247,6 +248,12 @@ internal class SingleWalletOnrampTransactionConverter( wrappedList(currency.name), ) } + this == OnrampStatus.Status.RefundInProgress -> { + resourceReference(R.string.express_exchange_status_refunding) + } + this == OnrampStatus.Status.Refunded -> { + resourceReference(R.string.express_exchange_status_refunded) + } else -> { resourceReference( R.string.express_exchange_status_sent, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt index ba80bbf6a3..99b787897e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt @@ -24,6 +24,7 @@ internal class VisaTxDetailsBottomSheetConverter( return VisaTxDetailsBottomSheetConfig( transaction = createTransaction(value), requests = value.requests.map(::createRequest).toImmutableList(), + onDisputeClick = { clickIntents.onDisputeClick(value) }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt index 439993d834..4cb5daf3c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.utils +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent.Companion.WALLET_TYPE import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.transactions.state.TxHistoryState @@ -103,7 +104,7 @@ internal class WalletLoadingStateFactory( WalletManageButton.Buy( enabled = true, dimContent = false, - onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId) }, + onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId, WALLET_TYPE) }, ), WalletManageButton.Swap( enabled = true, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt index 67a36a2935..57ed2a8b6d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt @@ -37,7 +37,7 @@ internal class WalletNFTListSubscriber( SetNFTCollectionsTransformer( userWalletId = userWallet.walletId, nftCollections = it, - onItemClick = { clickIntents.onNFTClick(userWallet.walletId) }, + onItemClick = { clickIntents.onNFTClick(userWallet) }, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt index 2f1df0fb3e..4007c48a5b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt @@ -6,6 +6,7 @@ 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.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +16,7 @@ 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 com.tangem.core.ui.components.SecondaryButtonIconStart import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -36,26 +38,45 @@ internal fun VisaTxDetailsBottomSheet(config: TangemBottomSheetConfig) { } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun VisaTxDetailsBottomSheetContent(config: VisaTxDetailsBottomSheetConfig, modifier: Modifier = Modifier) { - ContentContainer( - modifier = modifier, - blocksCount = config.requests.size.inc(), - title = { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size44) + .background(TangemTheme.colors.background.secondary), + contentAlignment = Alignment.Center, + ) { Text( text = stringResourceSafe(R.string.visa_transaction_details_header), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, ) - }, - block = { index -> - if (index == 0) { + } + + LazyColumn( + modifier = modifier.background(TangemTheme.colors.background.secondary), + contentPadding = PaddingValues( + bottom = TangemTheme.dimens.spacing16, + ), + verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { TransactionBlock(config.transaction) - } else { - BlockchainRequestBlock(config.requests[index - 1]) } - }, - ) + + items(config.requests) { item -> + BlockchainRequestBlock(item) + } + + item { + DisputeButton(config.onDisputeClick) + } + } + } } @Composable @@ -173,38 +194,14 @@ private fun BlockchainRequestBlock(request: VisaTxDetailsBottomSheetConfig.Reque ) } -@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContentContainer( - blocksCount: Int, - title: @Composable BoxScope.() -> Unit, - block: @Composable ColumnScope.(Int) -> Unit, - modifier: Modifier = Modifier, -) { - LazyColumn( - modifier = modifier.background(TangemTheme.colors.background.secondary), - contentPadding = PaddingValues( - bottom = TangemTheme.dimens.spacing16, - ), - verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - stickyHeader { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size44) - .background(TangemTheme.colors.background.secondary), - contentAlignment = Alignment.Center, - content = title, - ) - } - items(blocksCount) { index -> - Column { - block(index) - } - } - } +private fun DisputeButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + SecondaryButtonIconStart( + modifier = modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.visa_tx_dispute_button), + iconResId = R.drawable.ic_alert_triangle_20, + onClick = onClick, + ) } // region Preview @@ -249,20 +246,8 @@ private class VisaTxDetailsBottomSheetParameterProvider : txStatus = "confirmed", onExploreClick = {}, ), - VisaTxDetailsBottomSheetConfig.Request( - id = "524582128501966799", - type = "settlement", - status = "accepted", - blockchainAmount = "1.0614 USDT", - transactionAmount = "0.99 €", - currencyCode = "978", - errorCode = 0, - date = "2023-12-01 00:01:00.000 +0300", - txHash = "0x635841d5fbdf1087cdd929019c863ee88a7165e4340bc17ddd0b1d04dfb11daa", - txStatus = "confirmed", - onExploreClick = {}, - ), ), + onDisputeClick = {}, ), ), ) diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts index e85bbfc291..841499063e 100644 --- a/features/walletconnect/impl/build.gradle.kts +++ b/features/walletconnect/impl/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.common.routing) + implementation(projects.common.ui) /** Domain models */ implementation(projects.domain.qrScanning.models) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponent.kt new file mode 100644 index 0000000000..a03ae3a26d --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponent.kt @@ -0,0 +1,51 @@ +package com.tangem.features.walletconnect.connections.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.connections.ui.AlertsModalBottomSheet + +internal class AlertsComponent( + appComponentContext: AppComponentContext, + private val params: Params, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + params.alertType.onDismiss() + } + + @Composable + override fun BottomSheet() { + AlertsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = params.alertType.onDismiss, + content = params.alertType, + ), + ) + } + + internal data class Params(val alertType: AlertType) + + @Immutable + internal sealed class AlertType : TangemBottomSheetConfigContent { + abstract val onDismiss: () -> Unit + + data class VerifiedDomain(val appName: String, override val onDismiss: () -> Unit) : AlertType() + data class UnknownDomain(val onConnect: () -> Unit, override val onDismiss: () -> Unit) : AlertType() + data class UnsafeDomain(val onConnect: () -> Unit, override val onDismiss: () -> Unit) : AlertType() + data class UnsupportedNetworks(val appName: String, override val onDismiss: () -> Unit) : AlertType() + data class WcDisconnected(override val onDismiss: () -> Unit) : AlertType() + data class UnknownError(val errorCode: Int, override val onDismiss: () -> Unit) : AlertType() + data class WrongCardSelected(override val onDismiss: () -> Unit) : AlertType() + data class ConnectionTimeout(val onTryAgain: () -> Unit, override val onDismiss: () -> Unit) : AlertType() + data class MaliciousTransaction( + val descriptionMessage: String, + val onConnect: () -> Unit, + override val onDismiss: () -> Unit, + ) : AlertType() + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcAppInfoComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcAppInfoComponent.kt new file mode 100644 index 0000000000..bba2e61463 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcAppInfoComponent.kt @@ -0,0 +1,31 @@ +package com.tangem.features.walletconnect.connections.components + +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.connections.ui.WcAppInfoModalBottomSheet + +internal class WcAppInfoComponent( + private val appComponentContext: AppComponentContext, + private val params: Params, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + WcAppInfoModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, // todo(wc) will be fixed in next PR's + ), + ) + } + + data class Params(val onDismiss: () -> Unit) +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcAppInfoUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcAppInfoUM.kt new file mode 100644 index 0000000000..a588d1ddd0 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcAppInfoUM.kt @@ -0,0 +1,25 @@ +package com.tangem.features.walletconnect.connections.entity + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class WcAppInfoUM( + val appName: String, + val appIcon: String, + val isVerified: Boolean, + val appSubtitle: String, + val notificationUM: NotificationUM?, + val walletName: String, + val networksInfo: WcNetworksInfo, + val onDismiss: () -> Unit, + val onConnect: () -> Unit, +) : TangemBottomSheetConfigContent + +@Immutable +internal sealed class WcNetworksInfo { + data class MissingRequiredNetworkInfo(val network: String) : WcNetworksInfo() + data class ContainsAllRequiredNetworks(val icons: ImmutableList) : WcNetworksInfo() +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcConnectionsState.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcConnectionsState.kt index 62474e35b9..0eb7c4e19b 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcConnectionsState.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcConnectionsState.kt @@ -14,7 +14,7 @@ internal data class WcConnectionsState( internal data class WcConnectionsTopAppBarConfig( val startButtonUM: TopAppBarButtonUM, - val endActions: ImmutableList, + val disconnectAllItem: TangemDropdownMenuItem, ) internal data class WcConnectionsUM( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectionsModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectionsModel.kt index ada41bf7d8..a0b6c6aa91 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectionsModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectionsModel.kt @@ -6,15 +6,22 @@ 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.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.domain.walletconnect.usecase.WcSessionsUseCase import com.tangem.features.walletconnect.connections.entity.WcConnectionsState -import com.tangem.features.walletconnect.connections.ui.preview.WcConnectionsPreviewData +import com.tangem.features.walletconnect.connections.entity.WcConnectionsTopAppBarConfig +import com.tangem.features.walletconnect.connections.model.transformers.WcSessionsTransformer import com.tangem.features.walletconnect.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.* import timber.log.Timber import javax.inject.Inject @@ -24,19 +31,16 @@ internal class WcConnectionsModel @Inject constructor( private val router: Router, private val uiMessageSender: UiMessageSender, private val listenToQrScanningUseCase: ListenToQrScanningUseCase, + private val wcSessionsUseCase: WcSessionsUseCase, override val dispatchers: CoroutineDispatcherProvider, ) : Model() { - val uiState: StateFlow = MutableStateFlow( - WcConnectionsPreviewData.stateForModel( - onBackClicked = router::pop, - openQrScanScreen = ::openQrScanScreen, - disconnectAll = ::disconnectAll, - ), - ).asStateFlow() + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) init { listenQrUpdates() + listenWcSessions() } private fun listenQrUpdates() { @@ -49,6 +53,21 @@ internal class WcConnectionsModel @Inject constructor( .launchIn(modelScope) } + private fun listenWcSessions() { + wcSessionsUseCase.invoke() + .conflate() + .distinctUntilChanged() + .onEach { sessionsMap -> + uiState.update( + WcSessionsTransformer( + sessionsMap = sessionsMap, + openAppInfoModal = { Timber.d("Open app info for session: $it") }, + ), + ) + } + .launchIn(modelScope) + } + private fun openQrScanScreen() { router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.WalletConnect)) } @@ -69,4 +88,23 @@ internal class WcConnectionsModel @Inject constructor( ) uiMessageSender.send(message) } + + private fun getInitialState(): WcConnectionsState { + return WcConnectionsState( + topAppBarConfig = WcConnectionsTopAppBarConfig( + startButtonUM = TopAppBarButtonUM( + iconRes = R.drawable.ic_back_24, + onIconClicked = router::pop, + enabled = true, + ), + disconnectAllItem = TangemDropdownMenuItem( + title = resourceReference(R.string.wc_disconnect_all), + textColorProvider = { TangemTheme.colors.text.warning }, + onClick = ::disconnectAll, + ), + ), + connections = persistentListOf(), + onNewConnectionClick = ::openQrScanScreen, + ) + } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsTransformer.kt new file mode 100644 index 0000000000..1f3b8a9ebc --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsTransformer.kt @@ -0,0 +1,38 @@ +package com.tangem.features.walletconnect.connections.model.transformers + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.walletconnect.connections.entity.WcConnectedAppInfo +import com.tangem.features.walletconnect.connections.entity.WcConnectionsState +import com.tangem.features.walletconnect.connections.entity.WcConnectionsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class WcSessionsTransformer( + private val sessionsMap: Map>, + private val openAppInfoModal: (WcSession) -> Unit, +) : Transformer { + + override fun transform(prevState: WcConnectionsState): WcConnectionsState { + return prevState.copy( + connections = sessionsMap.map { (wallet, sessions) -> + WcConnectionsUM( + userWalletId = wallet.walletId.stringValue, + walletName = wallet.name, + connectedApps = sessions.map { session -> + with(session.sdkModel) { + WcConnectedAppInfo( + name = appMetaData.name, + iconUrl = appMetaData.icons.firstOrNull().orEmpty(), + subtitle = stringReference(appMetaData.description), + isVerified = false, + onClick = { openAppInfoModal(session) }, + ) + } + }.toPersistentList(), + ) + }.toPersistentList(), + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/AlertsModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/AlertsModalBottomSheet.kt new file mode 100644 index 0000000000..ae14e28008 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/AlertsModalBottomSheet.kt @@ -0,0 +1,355 @@ +package com.tangem.features.walletconnect.connections.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SecondaryButtonIconEnd +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +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.walletconnect.connections.components.AlertsComponent + +@Composable +internal fun AlertsModalBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + title = { alert -> + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = alert.onDismiss, + ) + }, + content = { alert -> Content(alert = alert) }, + ) +} + +@Composable +private fun Content(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + ContentContainer( + modifier = Modifier + .heightIn(min = TangemTheme.dimens.size180) + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(bottom = 32.dp), + alert = alert, + ) + ButtonsContainer(modifier = Modifier.fillMaxWidth(), alert = alert) + } +} + +@Composable +private fun ContentContainer(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + AlertIcon(alert = alert) + AlertContentTitle( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing24), + alert = alert, + ) + AlertContentDescription( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing8), + alert = alert, + ) + AlertAudit(alert, modifier = Modifier.padding(top = TangemTheme.dimens.spacing16)) + } +} + +@Suppress("LongMethod") +@Composable +private fun ButtonsContainer(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(all = TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + val buttonsModifier = Modifier.fillMaxWidth() + when (alert) { + is AlertsComponent.AlertType.UnsafeDomain -> { + PrimaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_cancel), + onClick = alert.onDismiss, + ) + SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onConnect, + text = stringResourceSafe(R.string.wc_alert_connect_anyway), + ) + } + is AlertsComponent.AlertType.MaliciousTransaction -> { + PrimaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_cancel), + onClick = alert.onDismiss, + ) + SecondaryButtonIconEnd( + modifier = buttonsModifier, + onClick = alert.onConnect, + iconResId = R.drawable.ic_tangem_24, + text = stringResourceSafe(R.string.wc_alert_connect_anyway), + ) + } + is AlertsComponent.AlertType.UnknownDomain -> { + PrimaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_cancel), + onClick = alert.onDismiss, + ) + SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onConnect, + text = stringResourceSafe(R.string.wc_alert_connect_anyway), + ) + } + is AlertsComponent.AlertType.VerifiedDomain -> SecondaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_done), + onClick = alert.onDismiss, + ) + is AlertsComponent.AlertType.ConnectionTimeout -> { + PrimaryButton( + modifier = buttonsModifier, + onClick = alert.onTryAgain, + text = stringResourceSafe(R.string.alert_button_try_again), + ) + SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onDismiss, + text = stringResourceSafe(R.string.balance_hidden_got_it_button), + ) + } + is AlertsComponent.AlertType.WrongCardSelected, + is AlertsComponent.AlertType.UnknownError, + -> SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onDismiss, + text = stringResourceSafe(R.string.balance_hidden_got_it_button), + ) + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.UnsupportedNetworks, + -> PrimaryButton( + modifier = buttonsModifier, + onClick = alert.onDismiss, + text = stringResourceSafe(R.string.balance_hidden_got_it_button), + ) + } + } +} + +@Composable +private fun AlertIcon(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + val color = when (alert) { + is AlertsComponent.AlertType.VerifiedDomain -> TangemTheme.colors.icon.accent + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.ConnectionTimeout, + is AlertsComponent.AlertType.UnsupportedNetworks, + -> TangemTheme.colors.icon.informative + is AlertsComponent.AlertType.WrongCardSelected, + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnknownError, + -> TangemTheme.colors.icon.attention + is AlertsComponent.AlertType.MaliciousTransaction, + is AlertsComponent.AlertType.UnsafeDomain, + -> TangemTheme.colors.icon.warning + } + + @DrawableRes val drawableId = when (alert) { + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.ConnectionTimeout, + -> R.drawable.ic_wallet_connect_24 + is AlertsComponent.AlertType.MaliciousTransaction -> R.drawable.img_knight_shield_32 + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + -> R.drawable.img_knight_shield_32 + is AlertsComponent.AlertType.UnsupportedNetworks -> R.drawable.ic_network_new_24 + is AlertsComponent.AlertType.WrongCardSelected, + is AlertsComponent.AlertType.UnknownError, + -> R.drawable.img_attention_20 + is AlertsComponent.AlertType.VerifiedDomain -> R.drawable.img_approvale2_20 + } + val iconTint = when (alert) { + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + is AlertsComponent.AlertType.MaliciousTransaction, + is AlertsComponent.AlertType.UnsupportedNetworks, + is AlertsComponent.AlertType.ConnectionTimeout, + is AlertsComponent.AlertType.WcDisconnected, + -> color + is AlertsComponent.AlertType.VerifiedDomain, + is AlertsComponent.AlertType.UnknownError, + is AlertsComponent.AlertType.WrongCardSelected, + -> Color.Unspecified + } + Box( + modifier = modifier + .size(TangemTheme.dimens.size56) + .clip(CircleShape) + .background(color.copy(alpha = 0.1F)), + contentAlignment = Alignment.Center, + content = { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size32), + painter = painterResource(drawableId), + contentDescription = null, + tint = iconTint, + ) + }, + ) +} + +@Composable +private fun AlertContentTitle(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + @StringRes val titleRes: Int = when (alert) { + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + is AlertsComponent.AlertType.MaliciousTransaction, + -> R.string.security_alert_title + is AlertsComponent.AlertType.ConnectionTimeout -> R.string.wc_alert_connection_timeout_title + is AlertsComponent.AlertType.UnknownError -> R.string.wc_alert_unknown_error_title + is AlertsComponent.AlertType.UnsupportedNetworks -> R.string.wc_alert_unsupported_networks_title + is AlertsComponent.AlertType.VerifiedDomain -> R.string.wc_alert_verified_domain_title + is AlertsComponent.AlertType.WcDisconnected -> R.string.wc_alert_session_disconnected_title + is AlertsComponent.AlertType.WrongCardSelected -> R.string.wc_alert_wrong_card_title + } + Text( + modifier = modifier, + text = stringResourceSafe(titleRes), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun AlertContentDescription(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + val message = when (alert) { + is AlertsComponent.AlertType.ConnectionTimeout -> stringResourceSafe( + R.string.wc_alert_connection_timeout_description, + ) + is AlertsComponent.AlertType.MaliciousTransaction -> alert.descriptionMessage + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + -> stringResourceSafe(R.string.wc_alert_domain_issues_description) + is AlertsComponent.AlertType.UnknownError -> stringResourceSafe( + R.string.wc_alert_unknown_error_description, + alert.errorCode, + ) + is AlertsComponent.AlertType.UnsupportedNetworks -> stringResourceSafe( + R.string.wc_alert_unsupported_networks_description, + alert.appName, + ) + is AlertsComponent.AlertType.VerifiedDomain -> stringResourceSafe( + R.string.wc_alert_verified_domain_description, + alert.appName, + ) + is AlertsComponent.AlertType.WcDisconnected -> stringResourceSafe( + R.string.wc_alert_session_disconnected_description, + ) + is AlertsComponent.AlertType.WrongCardSelected -> stringResourceSafe(R.string.wc_alert_wrong_card_description) + } + Text( + modifier = modifier, + text = message, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun AlertAudit(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + when (alert) { + is AlertsComponent.AlertType.UnknownDomain -> Text( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius16), + color = TangemTheme.colors.text.primary1.copy(alpha = 0.1F), + ) + .padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12), + text = stringResourceSafe(R.string.wc_alert_audit_unknown_domain), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + is AlertsComponent.AlertType.UnsafeDomain -> Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius16), + color = TangemTheme.colors.text.warning.copy(alpha = 0.1F), + ) + .padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12), + text = stringResourceSafe(R.string.wc_alert_audit_unknown_domain), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.warning, + ) + is AlertsComponent.AlertType.ConnectionTimeout, + is AlertsComponent.AlertType.MaliciousTransaction, + is AlertsComponent.AlertType.UnknownError, + is AlertsComponent.AlertType.UnsupportedNetworks, + is AlertsComponent.AlertType.VerifiedDomain, + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.WrongCardSelected, + -> Unit + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AlertsModalBottomSheet_Preview( + @PreviewParameter(AlertTypesProvider::class) alert: AlertsComponent.AlertType, +) { + TangemThemePreview { + AlertsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = alert, + ), + ) + } +} + +private class AlertTypesProvider : CollectionPreviewParameterProvider( + collection = listOf( + AlertsComponent.AlertType.VerifiedDomain(appName = "React App", onDismiss = {}), + AlertsComponent.AlertType.UnknownDomain(onConnect = {}, onDismiss = {}), + AlertsComponent.AlertType.UnsafeDomain(onConnect = {}, onDismiss = {}), + AlertsComponent.AlertType.MaliciousTransaction( + descriptionMessage = "The transaction approves erc20 tokens to a known malicious address", + onConnect = {}, + onDismiss = {}, + ), + AlertsComponent.AlertType.UnsupportedNetworks(appName = "React App", onDismiss = {}), + AlertsComponent.AlertType.WcDisconnected(onDismiss = {}), + AlertsComponent.AlertType.UnknownError(errorCode = 8005, onDismiss = {}), + AlertsComponent.AlertType.WrongCardSelected(onDismiss = {}), + AlertsComponent.AlertType.ConnectionTimeout(onTryAgain = {}, onDismiss = {}), + ), +) \ 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 new file mode 100644 index 0000000000..ca17e5f44f --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt @@ -0,0 +1,69 @@ +package com.tangem.features.walletconnect.connections.ui + +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.res.painterResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.walletconnect.impl.R + +@Composable +internal fun WcAppInfoItem( + iconUrl: String, + title: String, + subtitle: String, + isVerified: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + AsyncImage( + modifier = Modifier + .size(TangemTheme.dimens.size48) + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .background(Color.Black), + model = iconUrl, + contentDescription = title, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = title, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.h3, + ) + if (isVerified) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.img_approvale2_20), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + Text( + text = subtitle, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + ) + } + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoContent.kt new file mode 100644 index 0000000000..5ce783ebb7 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoContent.kt @@ -0,0 +1,447 @@ +package com.tangem.features.walletconnect.connections.ui + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +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.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +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.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +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.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.walletconnect.connections.entity.WcAppInfoUM +import com.tangem.features.walletconnect.connections.entity.WcNetworksInfo +import com.tangem.features.walletconnect.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun WcAppInfoModalBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + TangemModalBottomSheetTitle( + title = resourceReference(R.string.wc_wallet_connect), + endIconRes = R.drawable.ic_close_24, + onEndClick = state.onDismiss, + ) + }, + content = { state -> WcAppInfoModalBottomSheetContent(state) }, + ) +} + +@Composable +private fun WcAppInfoModalBottomSheetContent(state: WcAppInfoUM) { + Column(modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16)) { + val blocksModifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action) + .fillMaxWidth() + WcAppInfoFirstBlock(modifier = blocksModifier, state = state) + state.notificationUM?.config?.let { config -> + Notification( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing14), + config = config, + iconTint = TangemTheme.colors.icon.attention, + ) + } + WcAppInfoSecondBlock( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing14) + .then(blocksModifier), + state = state, + ) + WcAppInfoButtons( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing16), + onDismiss = state.onDismiss, + onConnect = state.onConnect, + ) + } +} + +@Composable +private fun WcAppInfoFirstBlock(state: WcAppInfoUM, modifier: Modifier = Modifier) { + var connectionRequestExpanded by remember { mutableStateOf(false) } + Column(modifier = modifier.animateContentSize()) { + WcAppInfoItem( + iconUrl = state.appIcon, + title = state.appName, + subtitle = state.appSubtitle, + isVerified = state.isVerified, + ) + HorizontalDivider(thickness = 1.dp, color = TangemTheme.colors.stroke.primary) + ConnectionRequestBlock( + modifier = Modifier + .fillMaxWidth() + .clickable { connectionRequestExpanded = !connectionRequestExpanded } + .padding(TangemTheme.dimens.spacing12), + expanded = connectionRequestExpanded, + ) + if (connectionRequestExpanded) { + ConnectionRequestDescription( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing12) + .padding(top = 8.dp, bottom = 12.dp), + ) + } + } +} + +@Composable +private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_connect_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .weight(1f), + text = stringResourceSafe(R.string.wc_connection_request), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Icon( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(width = 18.dp, height = 24.dp), + painter = painterResource(if (expanded) R.drawable.ic_chevron_up_24 else R.drawable.ic_chevron_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Composable +private fun ConnectionRequestDescription(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = stringResourceSafe(R.string.wc_connection_reqeust_would_like), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + ConnectionRequestDescriptionRow( + iconPainter = painterResource(R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + text = stringResourceSafe(R.string.wc_connection_reqeust_can_view_balance), + ) + ConnectionRequestDescriptionRow( + iconPainter = painterResource(R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + text = stringResourceSafe(R.string.wc_connection_reqeust_request_approval), + ) + HorizontalDivider( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + Text( + modifier = Modifier.padding(top = 12.dp), + text = stringResourceSafe(R.string.wc_connection_reqeust_will_not), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + ConnectionRequestDescriptionRow( + iconPainter = painterResource(R.drawable.ic_close_24), + tint = TangemTheme.colors.icon.warning, + text = stringResourceSafe(R.string.wc_connection_reqeust_cant_sign), + ) + } +} + +@Composable +private fun ConnectionRequestDescriptionRow( + iconPainter: Painter, + tint: Color, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing8), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size24), + painter = iconPainter, + contentDescription = null, + tint = tint, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = text, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +private fun WcAppInfoSecondBlock(state: WcAppInfoUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val itemsModifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing12) + WalletRowItem(modifier = itemsModifier, walletName = state.walletName) + HorizontalDivider(thickness = 1.dp, color = TangemTheme.colors.stroke.primary) + SelectNetworksBlock( + networksInfo = state.networksInfo, + modifier = itemsModifier, + ) + when (state.networksInfo) { + is WcNetworksInfo.ContainsAllRequiredNetworks -> Unit + is WcNetworksInfo.MissingRequiredNetworkInfo -> { + HorizontalDivider(thickness = 1.dp, color = TangemTheme.colors.stroke.primary) + Notification( + config = NotificationConfig( + iconResId = R.drawable.ic_alert_circle_24, + title = resourceReference(R.string.wc_missing_required_network_title), + subtitle = resourceReference( + R.string.wc_missing_required_network_description, + wrappedList(state.networksInfo.network), + ), + ), + iconTint = TangemTheme.colors.icon.attention, + containerColor = TangemTheme.colors.background.action, + ) + } + } + } +} + +@Composable +private fun WalletRowItem(walletName: String, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_wallet_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .weight(1f), + text = stringResourceSafe(R.string.manage_tokens_network_selector_wallet), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing16) + .weight(1f), + text = walletName, + textAlign = TextAlign.End, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + Icon( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(width = 18.dp, height = 24.dp), + painter = painterResource(R.drawable.ic_select_18_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Composable +private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_network_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .weight(1f), + text = stringResourceSafe(R.string.wc_common_networks), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + when (networksInfo) { + is WcNetworksInfo.ContainsAllRequiredNetworks -> NetworkIcons(icons = networksInfo.icons) + is WcNetworksInfo.MissingRequiredNetworkInfo -> Unit + } + Icon( + modifier = Modifier.size(width = 18.dp, height = 24.dp), + painter = painterResource( + id = when (networksInfo) { + is WcNetworksInfo.ContainsAllRequiredNetworks -> R.drawable.ic_select_18_24 + is WcNetworksInfo.MissingRequiredNetworkInfo -> R.drawable.ic_chevron_right_24 + }, + ), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun NetworkIcons(icons: ImmutableList, modifier: Modifier = Modifier) { + val (iconsToShow, remainingCount) = remember(icons) { + when { + icons.size <= 4 -> icons to 0 + else -> icons.take(3) to icons.size - 3 + } + } + Box(modifier = modifier.wrapContentWidth()) { + iconsToShow.forEachIndexed { index, iconRes -> + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing16.times(index)) + .border( + width = 2.dp, + color = TangemTheme.colors.background.action, + shape = CircleShape, + ) + .padding(2.dp) + .clip(CircleShape) + .size(20.dp), + ) + } + if (remainingCount > 0) { + Box( + modifier = modifier + .padding(start = 48.dp) + .border( + width = 2.dp, + color = TangemTheme.colors.background.action, + shape = CircleShape, + ) + .padding(2.dp) + .background(color = TangemTheme.colors.background.action) + .size(20.dp) + .clip(CircleShape) + .background(color = TangemTheme.colors.icon.primary1.copy(alpha = 0.1F)), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "+$remainingCount", + style = TangemTheme.typography.overline, + color = TangemTheme.colors.text.secondary, + ) + } + } + } +} + +@Composable +private fun WcAppInfoButtons(onDismiss: () -> Unit, onConnect: () -> Unit, modifier: Modifier = Modifier) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + text = stringResourceSafe(R.string.common_cancel), + onClick = onDismiss, + ) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + text = stringResourceSafe(R.string.wc_common_connect), + onClick = onConnect, + ) + } +} + +@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 WcAppInfoBottomSheetPreview(@PreviewParameter(WcAppInfoStateProvider::class) state: WcAppInfoUM) { + TangemThemePreview { + WcAppInfoModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = state, + ), + ) + } +} + +private class WcAppInfoStateProvider : CollectionPreviewParameterProvider( + collection = listOf( + WcAppInfoUM( + appName = "React App", + appIcon = "", + isVerified = true, + appSubtitle = "react-app.walletconnect.com", + notificationUM = null, + walletName = "Tangem 2.0", + networksInfo = WcNetworksInfo.ContainsAllRequiredNetworks( + icons = persistentListOf( + R.drawable.img_optimism_22, + R.drawable.img_bsc_22, + R.drawable.img_avalanche_22, + R.drawable.img_solana_22, + R.drawable.img_avalanche_22, + ), + ), + onDismiss = {}, + onConnect = {}, + ), + WcAppInfoUM( + appName = "React App", + appIcon = "", + isVerified = false, + appSubtitle = "react-app.walletconnect.com", + notificationUM = NotificationUM.Info( + title = resourceReference(R.string.wc_alert_audit_unknown_domain), + subtitle = resourceReference(R.string.wc_alert_domain_issues_description), + ), + walletName = "Tangem 2.0", + networksInfo = WcNetworksInfo.MissingRequiredNetworkInfo(network = "Solana"), + onDismiss = {}, + onConnect = {}, + ), + ), +) \ No newline at end of file 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 005ff5c7a4..e529ec9d02 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 @@ -259,12 +259,10 @@ private fun ConnectionsTopBar( modifier = Modifier.background(TangemTheme.colors.background.primary), onDismissRequest = { showDropdownMenu = false }, content = { - config.endActions.fastForEach { - TangemDropdownItem( - item = it, - dismissParent = { showDropdownMenu = false }, - ) - } + TangemDropdownItem( + item = config.disconnectAllItem, + dismissParent = { showDropdownMenu = false }, + ) }, ) }, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/preview/WcConnectionsPreviewData.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/preview/WcConnectionsPreviewData.kt index 57ce635a39..31759bb4d4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/preview/WcConnectionsPreviewData.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/preview/WcConnectionsPreviewData.kt @@ -10,7 +10,6 @@ import com.tangem.features.walletconnect.connections.entity.WcConnectionsTopAppB import com.tangem.features.walletconnect.connections.entity.WcConnectionsUM import com.tangem.features.walletconnect.impl.R import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import timber.log.Timber internal object WcConnectionsPreviewData { @@ -111,31 +110,14 @@ internal object WcConnectionsPreviewData { onIconClicked = {}, enabled = true, ), - endActions = persistentListOf( - TangemDropdownMenuItem( - title = resourceReference(R.string.wc_disconnect_all), - textColorProvider = { TangemTheme.colors.text.warning }, - onClick = { }, - ), + disconnectAllItem = TangemDropdownMenuItem( + title = resourceReference(R.string.wc_disconnect_all), + textColorProvider = { TangemTheme.colors.text.warning }, + onClick = { }, ), ), connections = persistentListOf(), onNewConnectionClick = {}, ) val fullState = stateWithEmptyConnections.copy(connections = connections) - fun stateForModel( - onBackClicked: () -> Unit, - openQrScanScreen: () -> Unit, - disconnectAll: () -> Unit, - ): WcConnectionsState { - return fullState.copy( - topAppBarConfig = fullState.topAppBarConfig.copy( - startButtonUM = fullState.topAppBarConfig.startButtonUM.copy(onIconClicked = onBackClicked), - endActions = fullState.topAppBarConfig.endActions - .map { it.copy(onClick = disconnectAll) } - .toImmutableList(), - ), - onNewConnectionClick = openQrScanScreen, - ) - } } \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 8fbee2f5e1..2353d61b82 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -102,6 +102,7 @@ espresso-intents = "3.5.1" junit = "4.13.2" junitAndroidExt = "1.1.5" mockk = "1.13.4" +turbine = "1.2.0" truth = "1.1.3" ultron-android = "2.5.4" ultron-compose = "2.5.4" @@ -199,6 +200,7 @@ test-junit = { module = "junit:junit", version.ref = "junit" } test-junit-android = { module = "androidx.test.ext:junit", version.ref = "junitAndroidExt" } test-truth = { module = "com.google.truth:truth", version.ref = "truth" } test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } test-ultron-android = { module = "com.atiurin:ultron-android", version.ref = "ultron-android" } test-ultron-compose = { module = "com.atiurin:ultron-compose", version.ref = "ultron-compose" } test-ultron-allure = { module = "com.atiurin:ultron-allure", version.ref = "ultron-allure" } @@ -212,6 +214,7 @@ test-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", versi amplitude = { module = "com.amplitude:android-sdk", version.ref = "amplitude" } armadillo = { module = "at.favre.lib:armadillo", version.ref = "armadillo" } coil = { module = "io.coil-kt:coil", version.ref = "coil" } +coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutine" } kotlin-coroutines-rx2 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2", version.ref = "coroutine" } kotlin-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutine" } diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt b/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt index ebca655e53..fa5fc99d83 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt @@ -1,10 +1,5 @@ package com.tangem.lib.auth interface ExpressAuthProvider { - - fun getUserId(): String - fun getSessionId(): String - - fun getRefCode(): String } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index 3decd72093..71205a6291 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -72,6 +72,11 @@ object BlockchainUtils { return blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet } + fun isTon(blockchainId: String): Boolean { + val blockchain = Blockchain.fromId(blockchainId) + return blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet + } + fun isSupportedNetworkId(blockchainId: String, excludedBlockchains: ExcludedBlockchains): Boolean { val blockchain = Blockchain.fromNetworkId(blockchainId) diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt deleted file mode 100644 index 8bdae098a7..0000000000 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.lib.crypto - -import com.tangem.blockchain.common.Amount -import com.tangem.lib.crypto.models.* -import java.math.BigInteger - -interface TransactionManager { - - /** - * Get fee - * - * @param networkId network id of blockchain - * @param amountToSend amount - * @param currencyToSend currency to send in tx - * @param destinationAddress address to send tx - * @param increaseBy percents in format 125 = 25% - * @param data data for tx - * @param derivationPath derivation path - * @return - */ - @Suppress("LongParameterList") - @Throws(IllegalStateException::class) - suspend fun getFee( - networkId: String, - amountToSend: Amount, - currencyToSend: Currency, - destinationAddress: String, - increaseBy: Int?, - data: String?, - derivationPath: String?, - ): ProxyFees - - @Throws(IllegalStateException::class) - suspend fun getFeeForGas(networkId: String, gas: BigInteger, derivationPath: String?): ProxyFees - - @Throws(IllegalStateException::class) - suspend fun updateWalletManager(networkId: String, derivationPath: String?) - - /** - * In app blockchain id, actual in blockchain sdk, not the same as networkId - * - * workaround till not use backend only and not integrated server vs sdk - */ - @Throws(IllegalStateException::class) - fun getBlockchainInfo(networkId: String): ProxyNetworkInfo - - @Throws(IllegalStateException::class) - fun getExplorerTransactionLink(networkId: String, txAddress: String): String -} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFee.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFee.kt deleted file mode 100644 index 6cb324fcef..0000000000 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFee.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.tangem.lib.crypto.models - -import java.math.BigDecimal -import java.math.BigInteger - -sealed interface ProxyFee { - - val gasLimit: BigInteger - val fee: ProxyAmount - - data class Common( - override val gasLimit: BigInteger, - override val fee: ProxyAmount, - ) : ProxyFee - - data class CardanoToken( - override val gasLimit: BigInteger, - override val fee: ProxyAmount, - val minAdaValue: BigDecimal, - ) : ProxyFee - - data class Filecoin( - override val gasLimit: BigInteger, - override val fee: ProxyAmount, - val gasPremium: Long, - ) : ProxyFee - - data class Sui( - override val gasLimit: BigInteger, - override val fee: ProxyAmount, - val gasPrice: Long, - val gasBudget: Long, - ) : ProxyFee -} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt deleted file mode 100644 index b8b3dfa295..0000000000 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.lib.crypto.models - -sealed class ProxyFees { - - data class MultipleFees( - val minFee: ProxyFee, - val normalFee: ProxyFee, - val priorityFee: ProxyFee, - ) : ProxyFees() - - data class SingleFee( - val singleFee: ProxyFee, - ) : ProxyFees() -} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyNetworkInfo.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyNetworkInfo.kt deleted file mode 100644 index dcbade5046..0000000000 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyNetworkInfo.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.lib.crypto.models - -data class ProxyNetworkInfo( - val name: String, - val blockchainId: String, -) \ No newline at end of file diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt index 6a6cfde251..e0a1deedfc 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt @@ -1,5 +1,7 @@ package com.tangem.sdk.api.di +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier import dagger.Module import dagger.Provides @@ -14,4 +16,9 @@ internal object CardSdkModule { @Provides @Singleton fun provideOnlineCardVerifier(): OnlineCardVerifier = OnlineCardVerifier() + + @Provides + @Singleton + fun provideCardArtworksProvider(sdkRepository: CardSdkConfigRepository): CardArtworksProvider = + CardArtworksProvider(sdkRepository.sdk.config.isTangemAttestationProdEnv) } \ No newline at end of file diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt index b90f1577cc..7eb52af432 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt @@ -8,4 +8,6 @@ package com.tangem.sdk.api.featuretoggles interface CardSdkFeatureToggles { val isNewAttestationEnabled: Boolean + + val isNewArtworkLoadingEnabled: Boolean } \ No newline at end of file diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt index 4146304590..0a12b3ce37 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt @@ -9,4 +9,7 @@ internal class DefaultCardSdkFeatureToggles @Inject constructor( override val isNewAttestationEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(name = "NEW_ATTESTATION_ENABLED") + + override val isNewArtworkLoadingEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "NEW_ARTWORK_LOADING") } \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/KotlinConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/KotlinConfigurations.kt index ae7e54965a..1ff0e4258f 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/KotlinConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/KotlinConfigurations.kt @@ -1,15 +1,15 @@ package com.tangem.plugin.configuration.configurations import org.gradle.api.Project -import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.withType -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -import org.jetbrains.kotlin.gradle.model.KotlinAndroidExtension +import org.jetbrains.kotlin.gradle.dsl.kotlinExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile internal fun Project.configureKotlinCompilerOptions() { + kotlinExtension.sourceSets.all { + // https://github.com/Kotlin/KEEP/blob/explicit-backing-fields-re/proposals/explicit-backing-fields.md + languageSettings.enableLanguageFeature("ExplicitBackingFields") + } project.tasks.withType { kotlinOptions { jvmTarget = "17" 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 89c5d2f72d..03d2b74d0c 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 @@ -69,17 +69,19 @@ private fun AppExtension.configureBuildTypes() { private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, buildType: BuildType) { when (buildType) { + BuildType.Release -> { + isDebuggable = false + } BuildType.Debug -> { isDebuggable = true } - BuildType.Internal -> { + BuildType.Internal, + BuildType.External + -> { initWith(appExtension.buildTypes.getByName(BuildType.Release.id)) matchingFallbacks.add(BuildType.Release.id) signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) } - BuildType.Release -> { - isDebuggable = false - } BuildType.Mocked -> { initWith(appExtension.buildTypes.getByName(BuildType.Release.id)) matchingFallbacks.add(BuildType.Release.id) diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt index 485c960dcb..bac38b3402 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt @@ -78,6 +78,27 @@ internal enum class BuildType( ), ), + /** + * Build type for QA and business + * + * Features: + * - Env: prod + * - Signing config: debug + * - Proguard + * */ + External( + id = "external", + appIdSuffix = "external", + versionSuffix = "external", + configFields = listOf( + BuildConfigField.Environment(value = "prod"), + BuildConfigField.TestActionEnabled(isEnabled = false), + BuildConfigField.LogEnabled(isEnabled = false), + BuildConfigField.TesterMenuAvailability(isEnabled = false), + BuildConfigField.MockDataSource(isEnabled = false), + ), + ), + /** * Production ready build type for clients * diff --git a/settings.gradle.kts b/settings.gradle.kts index ddcc0c9bc5..5aa3d2ba61 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -288,6 +288,11 @@ include(":domain:promo:models") include(":domain:nft") include(":domain:nft:models") include(":domain:networks") +include(":domain:quotes") +include(":domain:blockaid") +include(":domain:blockaid:models") +include(":domain:notifications") +include(":domain:notifications:models") // endregion Domain modules // region Data modules @@ -314,4 +319,7 @@ include(":data:manage-tokens") include(":data:networks") include(":data:nft") include(":data:onramp") +include(":data:quotes") +include(":data:notifications") +include(":data:blockaid") // endregion Data modules \ No newline at end of file