From 784051ddeb1fb8c397d657c7159836bd634bf02e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Jun 2025 11:49:50 +0500 Subject: [PATCH 01/10] Updated on 2026-08-14 --- .../tangem/data/nft/DefaultNFTRepository.kt | 4 +- .../collections/NFTCollectionsComponent.kt | 3 +- .../transformer/UpdateDataStateTransformer.kt | 8 +- .../collections/model/NFTCollectionsModel.kt | 4 +- .../nft/common/DefaultNFTComponent.kt | 6 +- .../tangem/features/nft/common/NFTRoute.kt | 3 +- .../nft/details/NFTDetailsComponent.kt | 3 +- .../entity/factory/NFTDetailsUMFactory.kt | 116 +++++++++--------- .../nft/details/model/NFTDetailsModel.kt | 4 +- 9 files changed, 78 insertions(+), 73 deletions(-) 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 f9e23d40cd..fd2e23dced 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 @@ -119,7 +119,9 @@ internal class DefaultNFTRepository @Inject constructor( userWalletId: UserWalletId, networks: List, ): Flow> = combine( - networks.map { observeCollectionsInternal(userWalletId, it) }, + networks + .sortedBy { it.name } + .map { observeCollectionsInternal(userWalletId, it) }, ) { it.asList() } private suspend fun observeCollectionsInternal( diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt index 209883f972..7881b5a08a 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt @@ -8,6 +8,7 @@ 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.nft.models.NFTCollection import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.nft.collections.model.NFTCollectionsModel import com.tangem.features.nft.collections.ui.NFTCollections @@ -31,7 +32,7 @@ internal class NFTCollectionsComponent @AssistedInject constructor( data class Params( val userWalletId: UserWalletId, val onBackClick: () -> Unit, - val onAssetClick: (asset: NFTAsset, collectionName: String) -> Unit, + val onAssetClick: (asset: NFTAsset, collection: NFTCollection) -> 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/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index 1a0bfc2607..da0d617e10 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 @@ -20,7 +20,7 @@ internal class UpdateDataStateTransformer( private val onRetryClick: () -> Unit, private val onExpandCollectionClick: (NFTCollection) -> Unit, private val onRetryAssetsClick: (NFTCollection) -> Unit, - private val onAssetClick: (NFTAsset, String) -> Unit, + private val onAssetClick: (NFTAsset, NFTCollection) -> Unit, private val initialSearchBarFactory: () -> SearchBarUM, private val collectionIdProvider: NFTCollection.() -> String, ) : Transformer { @@ -118,12 +118,12 @@ internal class UpdateDataStateTransformer( is NFTCollection.Assets.Value -> NFTCollectionAssetsListUM.Content( items = assets .items - .map { it.transform(name.orEmpty()) } + .map { it.transform(this) } .toPersistentList(), ) } - private fun NFTAsset.transform(collectionName: String): NFTCollectionAssetUM { + private fun NFTAsset.transform(collection: NFTCollection): NFTCollectionAssetUM { return NFTCollectionAssetUM( id = id.toString(), name = name ?: DASH_SIGN, @@ -144,7 +144,7 @@ internal class UpdateDataStateTransformer( ) }, onItemClick = { - onAssetClick(this, collectionName) + onAssetClick(this, collection) }, ) } 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 bf8bd9382e..5a5fe32b0f 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 @@ -79,8 +79,8 @@ internal class NFTCollectionsModel @Inject constructor( onRetryClick = ::onRefresh, onExpandCollectionClick = ::onExpandCollectionClick, onRetryAssetsClick = ::onRetryAssetsClick, - onAssetClick = { asset, collectionName -> - params.onAssetClick(asset, collectionName) + onAssetClick = { asset, collection -> + params.onAssetClick(asset, collection) }, initialSearchBarFactory = ::getInitialSearchBar, collectionIdProvider = collectionIdProvider, 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 index 3063f7ab6e..5c4874dd5e 100644 --- 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 @@ -112,12 +112,12 @@ internal class DefaultNFTComponent @AssistedInject constructor( ), ) }, - onAssetClick = { asset, collectionName -> + onAssetClick = { asset, collection -> innerRouter.push( NFTRoute.Details( userWalletId = route.userWalletId, nftAsset = asset, - collectionName = collectionName, + collection = collection, ), ) }, @@ -144,7 +144,7 @@ internal class DefaultNFTComponent @AssistedInject constructor( params = NFTDetailsComponent.Params( userWalletId = route.userWalletId, nftAsset = route.nftAsset, - nftCollectionName = route.collectionName, + nftCollection = route.collection, onBackClick = ::onChildBack, onAllTraitsClick = { innerRouter.push( 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 index 5c3482f2f6..b9076c71e0 100644 --- 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 @@ -2,6 +2,7 @@ package com.tangem.features.nft.common import com.tangem.core.decompose.navigation.Route import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.wallets.models.UserWalletId import kotlinx.serialization.Serializable @@ -21,7 +22,7 @@ internal sealed class NFTRoute : Route { data class Details( val userWalletId: UserWalletId, val nftAsset: NFTAsset, - val collectionName: String, + val collection: NFTCollection, ) : NFTRoute() @Serializable 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 index 67920ecced..6341df542e 100644 --- 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 @@ -14,6 +14,7 @@ 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.nft.models.NFTCollection import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.nft.details.entity.NFTDetailsBottomSheetConfig import com.tangem.features.nft.details.info.NFTDetailsInfoComponent @@ -65,7 +66,7 @@ internal class NFTDetailsComponent @AssistedInject constructor( data class Params( val userWalletId: UserWalletId, val nftAsset: NFTAsset, - val nftCollectionName: String, + val nftCollection: NFTCollection, val onBackClick: () -> Unit, val onAllTraitsClick: () -> Unit, ) 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 index 5fbb553580..66f7a6cae1 100644 --- 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 @@ -10,6 +10,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.nft.models.NFTSalePrice import com.tangem.features.nft.details.entity.NFTAssetUM import com.tangem.features.nft.details.entity.NFTDetailsUM @@ -29,8 +30,8 @@ internal class NFTDetailsUMFactory( private val onInfoBlockClick: (title: TextReference, text: TextReference) -> Unit, ) { - fun getInitialState(nftAsset: NFTAsset): NFTDetailsUM = NFTDetailsUM( - nftAsset = nftAsset.transform(), + fun getInitialState(nftAsset: NFTAsset, nftCollection: NFTCollection): NFTDetailsUM = NFTDetailsUM( + nftAsset = nftAsset.transform(nftCollection), pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, onRefresh = { onRefresh() }, @@ -42,63 +43,62 @@ internal class NFTDetailsUMFactory( onSendClick = onSendClick, ) - private fun NFTAsset.transform(): NFTAssetUM = NFTAssetUM( - name = name.orEmpty(), - media = media?.imageUrl?.let { - NFTAssetUM.Media.Content( - url = it, - ) - } ?: 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 = toSalePrice(), - description = description, - rarity = if (rarity != null) { - NFTAssetUM.Rarity.Content( - rank = rarity.rank, - label = rarity.label, - showDivider = hasSalePrice || hasDescription, - onLabelClick = { - onInfoBlockClick( - resourceReference(R.string.nft_details_rarity_label), - resourceReference(R.string.nft_details_info_rarity_label), - ) - }, - onRankClick = { - onInfoBlockClick( - resourceReference(R.string.nft_details_rarity_rank), - resourceReference(R.string.nft_details_info_rarity_rank), - ) - }, - ) - } else { - NFTAssetUM.Rarity.Empty - }, + private fun NFTAsset.transform(nftCollection: NFTCollection): NFTAssetUM { + val description = description?.takeIf { it.isNotEmpty() } ?: nftCollection.description + return NFTAssetUM( + name = name.orEmpty(), + media = media?.imageUrl?.let { + NFTAssetUM.Media.Content( + url = it, ) - } - }, - traits = traits.take(MAX_TRAITS_COUNT).map { - NFTAssetUM.BlockItem( - title = stringReference(it.name), - value = it.value, - showInfoButton = false, - ) - }.toImmutableList(), - showAllTraitsButton = traits.size > MAX_TRAITS_COUNT, - baseInfoItems = buildBaseInfoItems(), - ) + } ?: 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 = resourceReference(R.string.nft_details_last_sale_price).takeIf { hasSalePrice }, + salePrice = toSalePrice(), + description = description, + rarity = if (rarity != null) { + NFTAssetUM.Rarity.Content( + rank = rarity.rank, + label = rarity.label, + showDivider = hasSalePrice || hasDescription, + onLabelClick = { + onInfoBlockClick( + resourceReference(R.string.nft_details_rarity_label), + resourceReference(R.string.nft_details_info_rarity_label), + ) + }, + onRankClick = { + onInfoBlockClick( + resourceReference(R.string.nft_details_rarity_rank), + resourceReference(R.string.nft_details_info_rarity_rank), + ) + }, + ) + } else { + NFTAssetUM.Rarity.Empty + }, + ) + } + }, + traits = traits.take(MAX_TRAITS_COUNT).map { + NFTAssetUM.BlockItem( + title = stringReference(it.name), + value = it.value, + showInfoButton = false, + ) + }.toImmutableList(), + showAllTraitsButton = traits.size > MAX_TRAITS_COUNT, + baseInfoItems = buildBaseInfoItems(), + ) + } private fun NFTAsset.hasSalePrice() = salePrice !is NFTSalePrice.Empty && salePrice !is NFTSalePrice.Error 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 a8e34b30c7..852e5d2e40 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 @@ -74,7 +74,7 @@ internal class NFTDetailsModel @Inject constructor( private val _state by lazy { MutableStateFlow( - value = stateFactory.getInitialState(params.nftAsset), + value = stateFactory.getInitialState(params.nftAsset, params.nftCollection), ) } @@ -187,7 +187,7 @@ internal class NFTDetailsModel @Inject constructor( AppRoute.NFTSend( userWalletId = params.userWalletId, nftAsset = params.nftAsset, - nftCollectionName = params.nftCollectionName, + nftCollectionName = params.nftCollection.name.orEmpty(), ), ) } From cb121e851a8154d71e8014adc17fa3ce6de03342 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Jun 2025 10:10:57 +0300 Subject: [PATCH 02/10] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7d4da9d5f9..31f29bcef0 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1079" +tangemBlockchainSdk = "releases-5.25.0-1082" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-480" +tangemCardSdk = "releases-5.25.0-481" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 1a1daedacc4176a631a579e398e30c22b763116f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Jun 2025 21:39:23 +0300 Subject: [PATCH 03/10] Updated on 2026-08-14 --- .../domain/wallets/delegate/DefaultUserWalletsSyncDelegate.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/delegate/DefaultUserWalletsSyncDelegate.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/delegate/DefaultUserWalletsSyncDelegate.kt index 77a7a1644e..66633a3a6a 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/delegate/DefaultUserWalletsSyncDelegate.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/delegate/DefaultUserWalletsSyncDelegate.kt @@ -45,7 +45,7 @@ class DefaultUserWalletsSyncDelegate( raise(UpdateWalletError.NameAlreadyExists) } - return@withContext when ( + when ( val result = userWalletsListManager.update(userWalletId) { it.copy(name = name) } ) { From 27cf7b963b8072a50958ee0f54628c67b45d5dfa Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Jun 2025 12:16:13 +0300 Subject: [PATCH 04/10] Updated on 2026-08-14 --- .../data/tokens/repository/DefaultCurrenciesRepository.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 52142a7581..ce4c171722 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 @@ -502,7 +502,8 @@ internal class DefaultCurrenciesRepository( currencyRawId: CryptoCurrency.RawID, ): Flow>> { return userWalletsStore.userWallets.flatMapLatest { userWallets -> - userWallets.forEach { fetchTokensIfCacheExpired(userWallet = it, refresh = false) } + userWallets.filter { it.isMultiCurrency } + .forEach { fetchTokensIfCacheExpired(userWallet = it, refresh = false) } val userWalletsWithCurrencies = userWallets .filterNot(UserWallet::isLocked) From 8dc9304c06745d9911b9b73d3ec5371c8e7316d1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Jun 2025 17:08:27 +0500 Subject: [PATCH 05/10] Updated on 2026-08-14 --- .../tangem/common/routing/DeepLinkRoute.kt | 16 +++++- .../onramp/deeplink/BuyDeepLinkHandler.kt | 4 +- .../deeplink/BuyRedirectDeepLinkHandler.kt | 10 ++++ .../onramp/deeplink/SellDeepLinkHandler.kt | 8 +++ .../onramp/deeplink/SwapDeepLinkHandler.kt | 8 +++ .../DefaultBuyRedirectDeepLinkHandler.kt | 49 +++++++++++++++++++ .../deeplink/DefaultSellDeepLinkHandler.kt | 31 ++++++++++++ .../deeplink/DefaultSwapDeepLinkHandler.kt | 31 ++++++++++++ .../deeplink/di/OnrampDeeplinkModule.kt | 14 ++++++ ...dler.kt => SellRedirectDeepLinkHandler.kt} | 4 +- ... => DefaultSellRedirectDeepLinkHandler.kt} | 10 ++-- .../send/v2/deeplink/di/SendDeepLinkModule.kt | 6 +-- 12 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyRedirectDeepLinkHandler.kt create mode 100644 features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SellDeepLinkHandler.kt create mode 100644 features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SwapDeepLinkHandler.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyRedirectDeepLinkHandler.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSellDeepLinkHandler.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt rename features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/deeplink/{SellDeepLinkHandler.kt => SellRedirectDeepLinkHandler.kt} (67%) rename features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/{DefaultSellDeepLinkHandler.kt => DefaultSellRedirectDeepLinkHandler.kt} (91%) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index 26a1ac0ce5..b16393478e 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -8,14 +8,22 @@ sealed class DeepLinkRoute { override val host: String = "onramp" } - data object Sell : DeepLinkRoute() { + data object SellRedirect : DeepLinkRoute() { override val host: String = "redirect_sell" } - data object Buy : DeepLinkRoute() { + data object Sell : DeepLinkRoute() { + override val host: String = "sell" + } + + data object BuyRedirect : DeepLinkRoute() { override val host: String = "redirect" } + data object Buy : DeepLinkRoute() { + override val host: String = "buy" + } + data object Referral : DeepLinkRoute() { override val host: String = "referral" } @@ -39,6 +47,10 @@ sealed class DeepLinkRoute { data object MarketTokenDetail : DeepLinkRoute() { override val host: String = "token_chart" } + + data object Swap : DeepLinkRoute() { + override val host: String = "swap" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyDeepLinkHandler.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyDeepLinkHandler.kt index 75c2d61f29..13e8deb82e 100644 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyDeepLinkHandler.kt +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyDeepLinkHandler.kt @@ -1,10 +1,8 @@ package com.tangem.features.onramp.deeplink -import kotlinx.coroutines.CoroutineScope - interface BuyDeepLinkHandler { interface Factory { - fun create(coroutineScope: CoroutineScope): BuyDeepLinkHandler + fun create(): BuyDeepLinkHandler } } \ No newline at end of file diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyRedirectDeepLinkHandler.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyRedirectDeepLinkHandler.kt new file mode 100644 index 0000000000..da300b5cc2 --- /dev/null +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/BuyRedirectDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.onramp.deeplink + +import kotlinx.coroutines.CoroutineScope + +interface BuyRedirectDeepLinkHandler { + + interface Factory { + fun create(coroutineScope: CoroutineScope): BuyRedirectDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SellDeepLinkHandler.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SellDeepLinkHandler.kt new file mode 100644 index 0000000000..086f028f40 --- /dev/null +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SellDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.onramp.deeplink + +interface SellDeepLinkHandler { + + interface Factory { + fun create(): SellDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SwapDeepLinkHandler.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SwapDeepLinkHandler.kt new file mode 100644 index 0000000000..dce01989cc --- /dev/null +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/deeplink/SwapDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.onramp.deeplink + +interface SwapDeepLinkHandler { + + interface Factory { + fun create(): SwapDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyRedirectDeepLinkHandler.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyRedirectDeepLinkHandler.kt new file mode 100644 index 0000000000..3e3f70ce4a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyRedirectDeepLinkHandler.kt @@ -0,0 +1,49 @@ +package com.tangem.features.onramp.deeplink + +import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.tokens.GetCryptoCurrencyUseCase +import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent +import com.tangem.domain.wallets.models.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.features.onramp.OnrampFeatureToggles +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +internal class DefaultBuyRedirectDeepLinkHandler @AssistedInject constructor( + @Assisted scope: CoroutineScope, + onrampFeatureToggles: OnrampFeatureToggles, + getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, + analyticsEventHandler: AnalyticsEventHandler, +) : BuyRedirectDeepLinkHandler { + + init { + // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet + getSelectedWalletSyncUseCase().fold( + ifLeft = { + Timber.e("Error on getting user wallet: $it") + }, + ifRight = { userWallet -> + if (!onrampFeatureToggles.isFeatureEnabled && !userWallet.isMultiCurrency) { + scope.launch { + val cryptoCurrency = getCryptoCurrencyUseCase(userWallet.walletId).getOrElse { + Timber.e("Error on getting cryptoCurrency: $it") + return@launch + } + analyticsEventHandler.send(TokenScreenAnalyticsEvent.Bought(cryptoCurrency.symbol)) + } + } + }, + ) + } + + @AssistedFactory + interface Factory : BuyRedirectDeepLinkHandler.Factory { + override fun create(coroutineScope: CoroutineScope): DefaultBuyRedirectDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSellDeepLinkHandler.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSellDeepLinkHandler.kt new file mode 100644 index 0000000000..ee38f9501a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSellDeepLinkHandler.kt @@ -0,0 +1,31 @@ +package com.tangem.features.onramp.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import timber.log.Timber + +internal class DefaultSellDeepLinkHandler @AssistedInject constructor( + router: AppRouter, + getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, +) : SellDeepLinkHandler { + + init { + // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet + getSelectedWalletSyncUseCase().fold( + ifLeft = { + Timber.e("Error on getting user wallet: $it") + }, + ifRight = { userWallet -> + router.push(AppRoute.SellCrypto(userWallet.walletId)) + }, + ) + } + + @AssistedFactory + interface Factory : SellDeepLinkHandler.Factory { + override fun create(): DefaultSellDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt new file mode 100644 index 0000000000..7fff8ef48c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt @@ -0,0 +1,31 @@ +package com.tangem.features.onramp.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import timber.log.Timber + +internal class DefaultSwapDeepLinkHandler @AssistedInject constructor( + router: AppRouter, + getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, +) : SwapDeepLinkHandler { + + init { + // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet + getSelectedWalletSyncUseCase().fold( + ifLeft = { + Timber.e("Error on getting user wallet: $it") + }, + ifRight = { userWallet -> + router.push(AppRoute.SwapCrypto(userWallet.walletId)) + }, + ) + } + + @AssistedFactory + interface Factory : SwapDeepLinkHandler.Factory { + override fun create(): DefaultSwapDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/di/OnrampDeeplinkModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/di/OnrampDeeplinkModule.kt index 418baeccc3..9b44b6bd3d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/di/OnrampDeeplinkModule.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/di/OnrampDeeplinkModule.kt @@ -19,7 +19,21 @@ internal interface OnrampDeeplinkModule { @Singleton fun bindOnrampDeepLinkHandlerFactory(impl: DefaultOnrampDeepLinkHandler.Factory): OnrampDeepLinkHandler.Factory + @Binds + @Singleton + fun bindBuyRedirectDeepLinkHandler( + impl: DefaultBuyRedirectDeepLinkHandler.Factory, + ): BuyRedirectDeepLinkHandler.Factory + @Binds @Singleton fun bindBuyDeepLinkHandler(impl: DefaultBuyDeepLinkHandler.Factory): BuyDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindSellDeepLinkHandler(impl: DefaultSellDeepLinkHandler.Factory): SellDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindSwapDeepLinkHandler(impl: DefaultSwapDeepLinkHandler.Factory): SwapDeepLinkHandler.Factory } \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/deeplink/SellDeepLinkHandler.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/deeplink/SellRedirectDeepLinkHandler.kt similarity index 67% rename from features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/deeplink/SellDeepLinkHandler.kt rename to features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/deeplink/SellRedirectDeepLinkHandler.kt index 32d263693a..a9d945790b 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/deeplink/SellDeepLinkHandler.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/deeplink/SellRedirectDeepLinkHandler.kt @@ -2,9 +2,9 @@ package com.tangem.features.send.v2.api.deeplink import kotlinx.coroutines.CoroutineScope -interface SellDeepLinkHandler { +interface SellRedirectDeepLinkHandler { interface Factory { - fun create(coroutineScope: CoroutineScope, queryParams: Map): SellDeepLinkHandler + fun create(coroutineScope: CoroutineScope, queryParams: Map): SellRedirectDeepLinkHandler } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellDeepLinkHandler.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellRedirectDeepLinkHandler.kt similarity index 91% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellDeepLinkHandler.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellRedirectDeepLinkHandler.kt index 49aeba8204..5320f622ed 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellDeepLinkHandler.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/DefaultSellRedirectDeepLinkHandler.kt @@ -5,7 +5,7 @@ import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.domain.tokens.GetCryptoCurrencyUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler +import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -14,13 +14,13 @@ import kotlinx.coroutines.launch import timber.log.Timber @Suppress("ComplexCondition") -internal class DefaultSellDeepLinkHandler @AssistedInject constructor( +internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor( @Assisted scope: CoroutineScope, @Assisted queryParams: Map, appRouter: AppRouter, getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, -) : SellDeepLinkHandler { +) : SellRedirectDeepLinkHandler { init { val currencyId = queryParams[CURRENCY_ID_KEY] @@ -70,11 +70,11 @@ internal class DefaultSellDeepLinkHandler @AssistedInject constructor( } @AssistedFactory - interface Factory : SellDeepLinkHandler.Factory { + interface Factory : SellRedirectDeepLinkHandler.Factory { override fun create( coroutineScope: CoroutineScope, queryParams: Map, - ): DefaultSellDeepLinkHandler + ): DefaultSellRedirectDeepLinkHandler } private companion object { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/di/SendDeepLinkModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/di/SendDeepLinkModule.kt index 6cd2bca80d..4187cff72b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/di/SendDeepLinkModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/deeplink/di/SendDeepLinkModule.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.v2.deeplink.di -import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler -import com.tangem.features.send.v2.deeplink.DefaultSellDeepLinkHandler +import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler +import com.tangem.features.send.v2.deeplink.DefaultSellRedirectDeepLinkHandler import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,5 +14,5 @@ internal interface SendDeepLinkModule { @Binds @Singleton - fun bindFactory(impl: DefaultSellDeepLinkHandler.Factory): SellDeepLinkHandler.Factory + fun bindFactory(impl: DefaultSellRedirectDeepLinkHandler.Factory): SellRedirectDeepLinkHandler.Factory } \ No newline at end of file From ab27ff4bea2f05531f3abd8851f5faac77760a73 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Jun 2025 17:09:20 +0500 Subject: [PATCH 06/10] Updated on 2026-08-14 --- .../data/staking/DefaultStakingRepository.kt | 41 +++++++++++++++++ .../staking/GetStakingAvailabilityUseCase.kt | 10 ++++ .../staking/repositories/StakingRepository.kt | 5 ++ .../deeplink/DefaultStakingDeepLinkHandler.kt | 46 ++++++++++++------- 4 files changed, 86 insertions(+), 16 deletions(-) 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 fd08381f40..d6ce77cad7 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 @@ -246,6 +246,47 @@ internal class DefaultStakingRepository( } } + override suspend fun getStakingAvailabilitySync( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): StakingAvailability { + if (!checkFeatureToggleEnabled(cryptoCurrency.network.id)) { + return StakingAvailability.Unavailable + } + + if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) { + return StakingAvailability.Unavailable + } + + val rawCurrencyId = cryptoCurrency.id.rawCurrencyId + if (rawCurrencyId == null) { + return StakingAvailability.Unavailable + } + + val isSupportedInMobileApp = getSupportedIntegrationId(cryptoCurrency.id).isNullOrEmpty().not() + + val yields = getEnabledYieldsSync() + if (yields.isEmpty()) { + return StakingAvailability.TemporaryUnavailable + } + + val prefetchedYield = findPrefetchedYield( + yields = yields, + currencyId = rawCurrencyId, + symbol = cryptoCurrency.symbol, + ) + + return when { + prefetchedYield != null && isSupportedInMobileApp -> { + StakingAvailability.Available(prefetchedYield.id) + } + prefetchedYield == null && isSupportedInMobileApp -> { + StakingAvailability.TemporaryUnavailable + } + else -> StakingAvailability.Unavailable + } + } + private fun checkFeatureToggleEnabled(networkId: Network.ID): Boolean { return when (networkId.toBlockchain()) { Blockchain.TON -> stakingFeatureToggles.isTonStakingEnabled diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt index 5f704c96b5..b65f257b05 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt @@ -32,4 +32,14 @@ class GetStakingAvailabilityUseCase( it.right() }.catch { emit(stakingErrorResolver.resolve(it).left()) } } + + suspend fun invokeSync( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Either = Either.catch { + stakingRepository.getStakingAvailabilitySync( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + }.mapLeft(stakingErrorResolver::resolve) } \ 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 7ed0ee1686..e399fcd26c 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 @@ -37,6 +37,11 @@ interface StakingRepository { fun getStakingAvailability(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Flow + suspend fun getStakingAvailabilitySync( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): StakingAvailability + suspend fun getActions( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index cc791844e8..4b5a2a090c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -3,7 +3,12 @@ package com.tangem.features.staking.impl.deeplink import arrow.core.getOrElse import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY +import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY +import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetYieldUseCase +import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -15,6 +20,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber +@Suppress("LongParameterList") internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( @Assisted private val scope: CoroutineScope, @Assisted private val queryParams: Map, @@ -22,6 +28,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, private val getYieldUseCase: GetYieldUseCase, + private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, ) : StakingDeepLinkHandler { init { @@ -29,24 +36,28 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( } private fun handleDeepLink() { - // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet - val userWalletId = queryParams[WALLET_ID_KEY]?.let(::UserWalletId) - ?: getSelectedWalletSyncUseCase().getOrNull()?.walletId + val networkId = queryParams[NETWORK_ID_KEY] + val tokenId = queryParams[TOKEN_ID_KEY] - if (userWalletId == null) { + // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet + val selectedUserWalletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId + val walletId = queryParams[WALLET_ID_KEY]?.let(::UserWalletId) ?: selectedUserWalletId + + // If selected user wallet is different than from deeplink - ignore deeplink + // If selected user wallet is null - ignore deeplink + if (walletId != selectedUserWalletId || selectedUserWalletId == null) { Timber.e("Error on getting user wallet") return } - val networkId = queryParams[NETWORK_ID_KEY] - val tokenId = queryParams[TOKEN_ID_KEY] - scope.launch { - val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = userWalletId).getOrElse { + val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = selectedUserWalletId).getOrElse { Timber.e("Error on getting crypto currency list") return@launch }.firstOrNull { - it.network.backendId == networkId && it.id.rawCurrencyId?.value == tokenId + val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) + val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + isNetwork && isCurrency } if (cryptoCurrency == null) { @@ -60,6 +71,15 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( return@launch } + val isStakingEnabled = getStakingAvailabilityUseCase.invokeSync( + userWalletId = selectedUserWalletId, + cryptoCurrency = cryptoCurrency, + ).getOrNull() + + if (isStakingEnabled !is StakingAvailability.Available) { + return@launch + } + val yield = getYieldUseCase.invoke( cryptoCurrencyId = cryptoCurrency.id, symbol = cryptoCurrency.symbol, @@ -70,7 +90,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( appRouter.push( AppRoute.Staking( - userWalletId = userWalletId, + userWalletId = selectedUserWalletId, cryptoCurrencyId = cryptoCurrency.id, yieldId = yield.id, ), @@ -85,10 +105,4 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( queryParams: Map, ): DefaultStakingDeepLinkHandler } - - private companion object { - const val WALLET_ID_KEY = "walletId" - const val NETWORK_ID_KEY = "network_id" - const val TOKEN_ID_KEY = "token_id" - } } \ No newline at end of file From 2f7c994a61b6bf6b7db892f15571e84d79855bff Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Jun 2025 17:09:54 +0500 Subject: [PATCH 07/10] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 35 +++++- .../tap/routing/utils/DeepLinkFactory.kt | 40 ++++--- .../tap/routing/utils/DeepLinkFactoryTest.kt | 102 ++++++++++++++---- .../com/tangem/core/deeplink/DeeplinkConst.kt | 7 +- .../converter/PayloadToDeeplinkConverter.kt | 30 ++++-- .../PayloadToDeeplinkConverterTest.kt | 22 ++-- .../repository/MockStakingRepository.kt | 5 + features/markets/impl/build.gradle.kts | 4 +- ...efaultMarketsTokenDetailDeepLinkHandler.kt | 22 ++-- .../deeplink/DefaultBuyDeepLinkHandler.kt | 28 +---- features/referral/impl/build.gradle.kts | 2 + features/staking/impl/build.gradle.kts | 2 + ...hMarketTokenClickBottomSheetTransformer.kt | 3 +- .../DefaultTokenDetailsDeepLinkHandler.kt | 81 +++++++------- .../deeplink/DefaultWalletDeepLinkHandler.kt | 10 +- 15 files changed, 256 insertions(+), 137 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 06a868e0a4..ac76057c04 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ @@ -208,6 +208,39 @@ android:host="token_chart" android:scheme="tangem" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index f33be6d875..02afc7ecfe 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -4,12 +4,12 @@ import android.net.Uri import com.tangem.common.routing.AppRoute import com.tangem.common.routing.DeepLinkRoute import com.tangem.common.routing.DeepLinkScheme +import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler -import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler -import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler -import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler +import com.tangem.features.onramp.deeplink.* +import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -21,6 +21,7 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.transformLatest import timber.log.Timber @@ -29,9 +30,10 @@ import javax.inject.Inject @Suppress("LongParameterList") @ActivityScoped internal class DeepLinkFactory @Inject constructor( + private val cardSdkProvider: CardSdkProvider, private val onrampDeepLink: OnrampDeepLinkHandler.Factory, - private val sellDeepLink: SellDeepLinkHandler.Factory, - private val buyDeepLink: BuyDeepLinkHandler.Factory, + private val sellRedirectDeepLink: SellRedirectDeepLinkHandler.Factory, + private val buyRedirectDeepLink: BuyRedirectDeepLinkHandler.Factory, private val referralDeepLink: ReferralDeepLinkHandler.Factory, private val walletConnectDeepLink: WalletConnectDeepLinkHandler.Factory, private val walletDeepLink: WalletDeepLinkHandler.Factory, @@ -39,6 +41,9 @@ internal class DeepLinkFactory @Inject constructor( private val stakingDeepLink: StakingDeepLinkHandler.Factory, private val marketsDeepLink: MarketsDeepLinkHandler.Factory, private val marketsTokenDetailDeepLink: MarketsTokenDetailDeepLinkHandler.Factory, + private val buyDeepLink: BuyDeepLinkHandler.Factory, + private val sellDeepLink: SellDeepLinkHandler.Factory, + private val swapDeepLink: SwapDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -55,15 +60,19 @@ internal class DeepLinkFactory @Inject constructor( |- Received URI: $deeplinkUri """.trimIndent(), ) - permittedAppRoute - .transformLatest { isPermitted -> - if (isPermitted) { - lastDeepLink?.let { - launchDeepLink(it, coroutineScope, isFromOnNewIntent) - } - lastDeepLink = null + combine( + permittedAppRoute, + cardSdkProvider.sdk.uiVisibility(), + ) { isRoutePermitted, isCardSdkVisible -> + isRoutePermitted to isCardSdkVisible + }.transformLatest, Unit> { (isRoutePermitted, isCardSdkVisible) -> + if (isRoutePermitted && !isCardSdkVisible) { + lastDeepLink?.let { + launchDeepLink(it, coroutineScope, isFromOnNewIntent) } + lastDeepLink = null } + } .launchIn(coroutineScope) .saveIn(deepLinkHandlerJobHolder) } @@ -103,8 +112,8 @@ internal class DeepLinkFactory @Inject constructor( val queryParams = getQueryParams(deeplinkUri) when (deeplinkUri.host) { DeepLinkRoute.Onramp.host -> onrampDeepLink.create(coroutineScope, queryParams) - DeepLinkRoute.Sell.host -> sellDeepLink.create(coroutineScope, queryParams) - DeepLinkRoute.Buy.host -> buyDeepLink.create(coroutineScope) + DeepLinkRoute.SellRedirect.host -> sellRedirectDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.BuyRedirect.host -> buyRedirectDeepLink.create(coroutineScope) DeepLinkRoute.Referral.host -> referralDeepLink.create() DeepLinkRoute.Wallet.host -> walletDeepLink.create() DeepLinkRoute.TokenDetails.host -> tokenDetailsDeepLink.create( @@ -115,6 +124,9 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Staking.host -> stakingDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.Markets.host -> marketsDeepLink.create() DeepLinkRoute.MarketTokenDetail.host -> marketsTokenDetailDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.Buy.host -> buyDeepLink.create() + DeepLinkRoute.Sell.host -> sellDeepLink.create() + DeepLinkRoute.Swap.host -> swapDeepLink.create() else -> { Timber.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 8078f58b05..018f3b76b8 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -2,12 +2,12 @@ package com.tangem.tap.routing.utils import android.net.Uri import com.tangem.common.routing.AppRoute +import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler -import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler -import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler -import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler +import com.tangem.features.onramp.deeplink.* +import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -18,6 +18,7 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.* import org.junit.After import org.junit.Before @@ -30,10 +31,10 @@ class DeepLinkFactoryTest { private val onrampDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() } - private val sellDeepLinkFactory = mockk(relaxed = true) { + private val sellRedirectDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() } - private val buyDeepLinkFactory = mockk(relaxed = true) { + private val buyRedirectDeepLinkFactory = mockk(relaxed = true) { every { create(any()) } returns mockk() } private val referralDeepLinkFactory = mockk(relaxed = true) { @@ -57,7 +58,19 @@ class DeepLinkFactoryTest { private val marketsTokenDetailDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() } + private val sellDeepLinkFactory = mockk(relaxed = true) { + every { create() } returns mockk() + } + private val buyDeepLinkFactory = mockk(relaxed = true) { + every { create() } returns mockk() + } + private val swapDeepLinkFactory = mockk(relaxed = true) { + every { create() } returns mockk() + } + private val cardSdkProvider = mockk(relaxed = true) { + every { sdk.uiVisibility() } returns MutableStateFlow(false) + } private val mockedUri = mockk(relaxed = true) private val isFromOnNewIntent: Boolean = false @@ -65,16 +78,20 @@ class DeepLinkFactoryTest { private lateinit var testScope: TestScope private val deepLinkFactory = DeepLinkFactory( - onrampDeepLinkFactory, - sellDeepLinkFactory, - buyDeepLinkFactory, - referralDeepLinkFactory, - walletConnectDeepLinkFactory, - walletDeepLinkFactory, - tokenDetailsDeepLinkFactory, - stakingDeepLinkFactory, - marketsDeepLinkFactory, - marketsTokenDetailDeepLinkFactory, + cardSdkProvider = cardSdkProvider, + onrampDeepLink = onrampDeepLinkFactory, + sellRedirectDeepLink = sellRedirectDeepLinkFactory, + buyRedirectDeepLink = buyRedirectDeepLinkFactory, + referralDeepLink = referralDeepLinkFactory, + walletConnectDeepLink = walletConnectDeepLinkFactory, + walletDeepLink = walletDeepLinkFactory, + tokenDetailsDeepLink = tokenDetailsDeepLinkFactory, + stakingDeepLink = stakingDeepLinkFactory, + marketsDeepLink = marketsDeepLinkFactory, + marketsTokenDetailDeepLink = marketsTokenDetailDeepLinkFactory, + buyDeepLink = buyDeepLinkFactory, + sellDeepLink = sellDeepLinkFactory, + swapDeepLink = swapDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -140,6 +157,24 @@ class DeepLinkFactoryTest { verify(inverse = true) { onrampDeepLinkFactory.create(any(), any()) } } + @Test + fun `handleDeeplink does not launch when card scan visible`() = runTest { + every { mockedUri.scheme } returns "tangem" + every { mockedUri.host } returns "onramp" + every { mockedUri.query } returns "param=value" + every { mockedUri.queryParameterNames } returns setOf("param") + every { mockedUri.getQueryParameter("param") } returns "value" + every { cardSdkProvider.sdk.uiVisibility() } returns MutableStateFlow(true) + + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) + + advanceUntilIdle() + + // Verify no handler was called + verify(inverse = true) { onrampDeepLinkFactory.create(any(), any()) } + } + @Test fun `launchDeepLink handles tangem scheme correctly`() = runTest { every { mockedUri.scheme } returns "tangem" @@ -184,8 +219,8 @@ class DeepLinkFactoryTest { verify(inverse = true) { onrampDeepLinkFactory.create(any(), any()) - sellDeepLinkFactory.create(any(), any()) - buyDeepLinkFactory.create(any()) + sellRedirectDeepLinkFactory.create(any(), any()) + buyRedirectDeepLinkFactory.create(any()) referralDeepLinkFactory.create() walletConnectDeepLinkFactory.create(any()) walletDeepLinkFactory.create() @@ -208,11 +243,11 @@ class DeepLinkFactoryTest { advanceUntilIdle() verify { onrampDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) } - // Test Sell + // Test Sell Redirect every { mockedUri.host } returns "redirect_sell" deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() - verify { sellDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) } + verify { sellRedirectDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) } // Test Token Details every { mockedUri.host } returns "token" @@ -242,11 +277,11 @@ class DeepLinkFactoryTest { every { mockedUri.queryParameterNames } returns emptySet() every { mockedUri.getQueryParameter(any()) } returns "" - // Test Buy + // Test Buy Redirect every { mockedUri.host } returns "redirect" deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() - verify { buyDeepLinkFactory.create(eq(testScope)) } + verify { buyRedirectDeepLinkFactory.create(eq(testScope)) } // Test Referral every { mockedUri.host } returns "referral" @@ -265,6 +300,24 @@ class DeepLinkFactoryTest { deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { marketsDeepLinkFactory.create() } + + // Test Sell + every { mockedUri.host } returns "sell" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { sellDeepLinkFactory.create() } + + // Test Swap + every { mockedUri.host } returns "swap" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { sellDeepLinkFactory.create() } + + // Test Buy + every { mockedUri.host } returns "buy" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { buyDeepLinkFactory.create() } } @Test @@ -279,12 +332,15 @@ class DeepLinkFactoryTest { verify(inverse = true) { onrampDeepLinkFactory.create(any(), any()) - sellDeepLinkFactory.create(any(), any()) - buyDeepLinkFactory.create(any()) + sellRedirectDeepLinkFactory.create(any(), any()) + buyRedirectDeepLinkFactory.create(any()) referralDeepLinkFactory.create() walletConnectDeepLinkFactory.create(any()) walletDeepLinkFactory.create() tokenDetailsDeepLinkFactory.create(any(), any(), any()) + buyDeepLinkFactory.create() + sellDeepLinkFactory.create() + swapDeepLinkFactory.create() } } diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeeplinkConst.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeeplinkConst.kt index cf598f7b4f..e0ffec3703 100644 --- a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeeplinkConst.kt +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeeplinkConst.kt @@ -1,11 +1,12 @@ package com.tangem.core.deeplink object DeeplinkConst { - @Deprecated("Used only for push notifications mapping, use `WALLET_ID_KEY` only") - const val PAYLOAD_WALLET_ID_KEY = "user_wallet_id" const val TANGEM_SCHEME = "tangem" - const val WALLET_ID_KEY = "walletId" + const val WALLET_ID_KEY = "user_wallet_id" const val NETWORK_ID_KEY = "network_id" const val TYPE_KEY = "type" const val TOKEN_ID_KEY = "token_id" + const val DERIVATION_PATH_KEY = "derivation_path" + const val TRANSACTION_ID_KEY = "transaction_id" + const val NAME_KEY = "name" } \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverter.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverter.kt index 8470ffbb71..f6ce9ad32e 100644 --- a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverter.kt +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverter.kt @@ -3,9 +3,11 @@ package com.tangem.core.deeplink.converter import com.tangem.common.routing.DeepLinkRoute import com.tangem.common.routing.DeepLinkScheme import com.tangem.core.deeplink.DEEPLINK_KEY +import com.tangem.core.deeplink.DeeplinkConst.DERIVATION_PATH_KEY +import com.tangem.core.deeplink.DeeplinkConst.NAME_KEY import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY -import com.tangem.core.deeplink.DeeplinkConst.PAYLOAD_WALLET_ID_KEY import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.core.deeplink.DeeplinkConst.TRANSACTION_ID_KEY import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY import com.tangem.utils.converter.Converter @@ -25,21 +27,29 @@ object PayloadToDeeplinkConverter : Converter, String?> { val type = payload[TYPE_KEY] ?: return null val networkId = payload[NETWORK_ID_KEY] ?: return null val tokenId = payload[TOKEN_ID_KEY] ?: return null - val walletId = payload[PAYLOAD_WALLET_ID_KEY] ?: return null + val walletId = payload[WALLET_ID_KEY] ?: return null + val derivationPath = payload[DERIVATION_PATH_KEY] ?: return null + val transactionId = payload[TRANSACTION_ID_KEY] + val name = payload[NAME_KEY] - return DeepLinkBuilder().setScheme(DeepLinkScheme.Tangem.scheme) - .setAction(DeepLinkRoute.TokenDetails.host) - .addQueryParam(NETWORK_ID_KEY, networkId) - .addQueryParam(TOKEN_ID_KEY, tokenId) - .addQueryParam(TYPE_KEY, type) - .addQueryParam(WALLET_ID_KEY, walletId) - .build() + return DeepLinkBuilder().setScheme(DeepLinkScheme.Tangem.scheme).apply { + setAction(DeepLinkRoute.TokenDetails.host) + addQueryParam(NETWORK_ID_KEY, networkId) + addQueryParam(TOKEN_ID_KEY, tokenId) + addQueryParam(TYPE_KEY, type) + addQueryParam(WALLET_ID_KEY, walletId) + addQueryParam(DERIVATION_PATH_KEY, derivationPath) + + transactionId?.let { addQueryParam(TRANSACTION_ID_KEY, it) } + name?.let { addQueryParam(NAME_KEY, it) } + }.build() } private fun isTangemPushNotificationPayload(payload: Map): Boolean { return payload.containsKey(TYPE_KEY) && payload.containsKey(NETWORK_ID_KEY) && payload.containsKey(TOKEN_ID_KEY) && - payload.containsKey(PAYLOAD_WALLET_ID_KEY) + payload.containsKey(WALLET_ID_KEY) && + payload.containsKey(DERIVATION_PATH_KEY) } } \ No newline at end of file diff --git a/core/deep-links/src/test/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverterTest.kt b/core/deep-links/src/test/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverterTest.kt index 697ff01d29..f3887c86e9 100644 --- a/core/deep-links/src/test/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverterTest.kt +++ b/core/deep-links/src/test/kotlin/com/tangem/core/deeplink/converter/PayloadToDeeplinkConverterTest.kt @@ -2,10 +2,11 @@ package com.tangem.core.deeplink.converter import com.google.common.truth.Truth.assertThat import com.tangem.core.deeplink.DEEPLINK_KEY +import com.tangem.core.deeplink.DeeplinkConst.DERIVATION_PATH_KEY import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY -import com.tangem.core.deeplink.DeeplinkConst.PAYLOAD_WALLET_ID_KEY import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY +import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY import org.junit.Test internal class PayloadToDeeplinkConverterTest { @@ -14,7 +15,8 @@ internal class PayloadToDeeplinkConverterTest { fun `GIVEN payload with deeplink key WHEN convert THEN should return deeplink value`() { // GIVEN val payload = mapOf( - DEEPLINK_KEY to "tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&walletId=wallet123", + DEEPLINK_KEY to "tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&user_wallet_id=wallet123" + + "&derivation_path=m'0'0'0", ) // WHEN @@ -22,7 +24,7 @@ internal class PayloadToDeeplinkConverterTest { // THEN assertThat(result).isEqualTo( - "tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&walletId=wallet123", + "tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&user_wallet_id=wallet123&derivation_path=m'0'0'0", ) } @@ -33,7 +35,8 @@ internal class PayloadToDeeplinkConverterTest { TYPE_KEY to "token", NETWORK_ID_KEY to "ethereum", TOKEN_ID_KEY to "0x123", - PAYLOAD_WALLET_ID_KEY to "wallet123", + WALLET_ID_KEY to "wallet123", + DERIVATION_PATH_KEY to "m'0'0'0", ) // WHEN @@ -41,7 +44,7 @@ internal class PayloadToDeeplinkConverterTest { // THEN assertThat(result).isEqualTo( - "tangem://token?network_id=ethereum&token_id=0x123&type=token&walletId=wallet123", + "tangem://token?network_id=ethereum&token_id=0x123&type=token&user_wallet_id=wallet123&derivation_path=m'0'0'0", ) } @@ -51,7 +54,8 @@ internal class PayloadToDeeplinkConverterTest { val payload = mapOf( NETWORK_ID_KEY to "ethereum", TOKEN_ID_KEY to "0x123", - PAYLOAD_WALLET_ID_KEY to "wallet123", + WALLET_ID_KEY to "wallet123", + DERIVATION_PATH_KEY to "m'0'0'0", ) // WHEN @@ -67,7 +71,8 @@ internal class PayloadToDeeplinkConverterTest { val payload = mapOf( TYPE_KEY to "token", TOKEN_ID_KEY to "0x123", - PAYLOAD_WALLET_ID_KEY to "wallet123", + WALLET_ID_KEY to "wallet123", + DERIVATION_PATH_KEY to "m'0'0'0", ) // WHEN @@ -83,7 +88,8 @@ internal class PayloadToDeeplinkConverterTest { val payload = mapOf( TYPE_KEY to "token", NETWORK_ID_KEY to "ethereum", - PAYLOAD_WALLET_ID_KEY to "wallet123", + WALLET_ID_KEY to "wallet123", + DERIVATION_PATH_KEY to "m'0'0'0", ) // WHEN 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 c53411a627..303da59cda 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 @@ -48,6 +48,11 @@ class MockStakingRepository : StakingRepository { cryptoCurrency: CryptoCurrency, ): Flow = flowOf(StakingAvailability.Unavailable) + override suspend fun getStakingAvailabilitySync( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): StakingAvailability = StakingAvailability.Unavailable + override suspend fun getActions( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 499ba281a4..2ce867d718 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -15,7 +15,6 @@ dependencies { /* Project - API */ api(projects.features.markets.api) api(projects.features.onramp.api) - implementation(projects.core.navigation) /* Data */ implementation(projects.data.common) @@ -38,6 +37,7 @@ dependencies { implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.settings) + implementation(projects.domain.notifications.models) // FIXME [REDACTED_TASK_KEY] // Remove the "Buy" and "Sell" actions from the redux middleware. @@ -73,6 +73,8 @@ dependencies { implementation(projects.core.configToggles) implementation(projects.core.analytics) implementation(projects.core.analytics.models) + implementation(projects.core.navigation) + implementation(projects.core.deepLinks) /* Common */ implementation(projects.common.ui) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt index 5007ac70ab..f9d5ca176b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt @@ -3,6 +3,7 @@ package com.tangem.features.markets.deeplink import arrow.core.getOrElse import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -17,14 +18,18 @@ import kotlinx.coroutines.launch import timber.log.Timber internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject constructor( - @Assisted scope: CoroutineScope, - @Assisted queryParams: Map, - appRouter: AppRouter, - getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + @Assisted private val scope: CoroutineScope, + @Assisted private val queryParams: Map, + private val appRouter: AppRouter, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, ) : MarketsTokenDetailDeepLinkHandler { init { + handleDeepLink() + } + + private fun handleDeepLink() { val tokenId = queryParams[TOKEN_ID_KEY] val rawTokenId = CryptoCurrency.RawID(tokenId.orEmpty()) @@ -36,7 +41,7 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc val tokenInfo = getTokenMarketInfoUseCase( appCurrency = appCurrency, tokenId = rawTokenId, - tokenSymbol = TOKEN_SYMBOL_KEY, + tokenSymbol = "", // used for analytics ).getOrElse { Timber.e("Failed to get market token info") return@launch @@ -71,9 +76,4 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc queryParams: Map, ): DefaultMarketsTokenDetailDeepLinkHandler } - - private companion object { - const val TOKEN_ID_KEY = "token_id" - const val TOKEN_SYMBOL_KEY = "token_symbol" - } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyDeepLinkHandler.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyDeepLinkHandler.kt index 32029a1244..5633cf6424 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyDeepLinkHandler.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultBuyDeepLinkHandler.kt @@ -1,25 +1,15 @@ package com.tangem.features.onramp.deeplink -import arrow.core.getOrElse -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.domain.tokens.GetCryptoCurrencyUseCase -import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent -import com.tangem.domain.wallets.models.isMultiCurrency +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.features.onramp.OnrampFeatureToggles -import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import timber.log.Timber internal class DefaultBuyDeepLinkHandler @AssistedInject constructor( - @Assisted scope: CoroutineScope, - onrampFeatureToggles: OnrampFeatureToggles, + router: AppRouter, getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, - analyticsEventHandler: AnalyticsEventHandler, ) : BuyDeepLinkHandler { init { @@ -29,21 +19,13 @@ internal class DefaultBuyDeepLinkHandler @AssistedInject constructor( Timber.e("Error on getting user wallet: $it") }, ifRight = { userWallet -> - if (!onrampFeatureToggles.isFeatureEnabled && !userWallet.isMultiCurrency) { - scope.launch { - val cryptoCurrency = getCryptoCurrencyUseCase(userWallet.walletId).getOrElse { - Timber.e("Error on getting cryptoCurrency: $it") - return@launch - } - analyticsEventHandler.send(TokenScreenAnalyticsEvent.Bought(cryptoCurrency.symbol)) - } - } + router.push(AppRoute.BuyCrypto(userWallet.walletId)) }, ) } @AssistedFactory interface Factory : BuyDeepLinkHandler.Factory { - override fun create(coroutineScope: CoroutineScope): DefaultBuyDeepLinkHandler + override fun create(): DefaultBuyDeepLinkHandler } } \ No newline at end of file diff --git a/features/referral/impl/build.gradle.kts b/features/referral/impl/build.gradle.kts index 89e4f4f57c..34f1402a0b 100644 --- a/features/referral/impl/build.gradle.kts +++ b/features/referral/impl/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(projects.core.utils) implementation(projects.core.ui) implementation(projects.core.decompose) + implementation(projects.core.deepLinks) implementation(projects.libs.crypto) implementation(projects.common.routing) @@ -41,6 +42,7 @@ dependencies { implementation(projects.domain.wallets) implementation(projects.domain.legacy) implementation(projects.domain.wallets.models) + implementation(projects.domain.notifications.models) implementation(projects.features.referral.domain) /** Other libraries */ diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index 05decae6d5..ebfa0a6ffd 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.core.decompose) + implementation(projects.core.deepLinks) /** Domain */ @@ -66,6 +67,7 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) + implementation(projects.domain.notifications.models) /** Common */ implementation(projects.common.ui) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenClickBottomSheetTransformer.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenClickBottomSheetTransformer.kt index 08f232befb..58fcfdbf4e 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenClickBottomSheetTransformer.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/testpush/viewmodel/transformers/markettokens/TestPushMarketTokenClickBottomSheetTransformer.kt @@ -19,8 +19,7 @@ internal class TestPushMarketTokenClickBottomSheetTransformer( append(DeepLinkRoute.MarketTokenDetail.host) append("?token_id=") append(tokenMarket.id) - append("&token_symbol=") - append(tokenMarket.symbol) + append("&type=promo") }, ), ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index 41456e71f5..379b813c06 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -39,56 +39,57 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } private fun handleDeepLink() { - // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet - val userWalletId = queryParams[WALLET_ID_KEY]?.let(::UserWalletId) - ?: getSelectedWalletSyncUseCase().getOrNull()?.walletId - - if (userWalletId == null) { - Timber.e("Error on getting user wallet") - return - } - val networkId = queryParams[NETWORK_ID_KEY] val tokenId = queryParams[TOKEN_ID_KEY] val type = NotificationType.getType(queryParams[TYPE_KEY]) - if (type != NotificationType.Unknown) { - scope.launch { - val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = userWalletId).getOrElse { - Timber.e("Error on getting crypto currency list") - return@launch - }.firstOrNull { - it.network.backendId == networkId && - it.id.rawCurrencyId?.value == tokenId - } + // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet + val selectedUserWalletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId + val walletId = queryParams[WALLET_ID_KEY]?.let(::UserWalletId) ?: selectedUserWalletId - if (cryptoCurrency == null) { - Timber.e( - """ + // If selected user wallet is different than from deeplink - ignore deeplink + // If selected user wallet is null - ignore deeplink + if (walletId != selectedUserWalletId || selectedUserWalletId == null) { + Timber.e("Error on getting user wallet") + return + } + + scope.launch { + val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = selectedUserWalletId).getOrElse { + Timber.e("Error on getting crypto currency list") + return@launch + }.firstOrNull { + val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) + val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + isNetwork && isCurrency + } + + if (cryptoCurrency == null) { + Timber.e( + """ Could not get crypto currency for |- $NETWORK_ID_KEY: $networkId |- $TOKEN_ID_KEY: $tokenId - """.trimIndent(), - ) - return@launch - } - - analyticsEventHandler.send(PushNotificationAnalyticEvents.NotificationOpened(type.name)) - - appRouter.push( - AppRoute.CurrencyDetails( - userWalletId = userWalletId, - currency = cryptoCurrency, - ), + """.trimIndent(), ) + return@launch + } - if (isFromOnNewIntent) { - fetchCurrencyStatusUseCase.invoke( - userWalletId = userWalletId, - id = cryptoCurrency.id, - refresh = true, - ) - } + analyticsEventHandler.send(PushNotificationAnalyticEvents.NotificationOpened(type.name)) + + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = selectedUserWalletId, + currency = cryptoCurrency, + ), + ) + + if (isFromOnNewIntent) { + fetchCurrencyStatusUseCase.invoke( + userWalletId = selectedUserWalletId, + id = cryptoCurrency.id, + refresh = true, + ) } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkHandler.kt index 508333060f..35d56c1bec 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkHandler.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkHandler.kt @@ -1,10 +1,18 @@ package com.tangem.feature.wallet.deeplink +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -internal class DefaultWalletDeepLinkHandler @AssistedInject constructor() : WalletDeepLinkHandler { +internal class DefaultWalletDeepLinkHandler @AssistedInject constructor( + router: AppRouter, +) : WalletDeepLinkHandler { + + init { + router.popTo(AppRoute.Wallet) + } @AssistedFactory interface Factory : WalletDeepLinkHandler.Factory { From e3b1480e329ba20343a8729cba10288c2c772737 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Jun 2025 16:23:56 +0400 Subject: [PATCH 08/10] Updated on 2026-08-14 --- data/staking/build.gradle.kts | 7 +- .../DefaultMultiYieldBalanceFetcherV2.kt | 210 ++++++++ .../DefaultSingleYieldBalanceFetcherV2.kt | 30 ++ .../DefaultMultiYieldBalanceFetcherV2Test.kt | 470 ++++++++++++++++++ .../DefaultSingleYieldBalanceFetcherV2Test.kt | 95 ++++ .../staking/multi/MultiYieldBalanceFetcher.kt | 17 +- .../single/SingleYieldBalanceFetcher.kt | 19 +- 7 files changed, 845 insertions(+), 3 deletions(-) create mode 100644 data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2.kt create mode 100644 data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2Test.kt create mode 100644 data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2Test.kt diff --git a/data/staking/build.gradle.kts b/data/staking/build.gradle.kts index b4d8282fac..6ca20ef8a8 100644 --- a/data/staking/build.gradle.kts +++ b/data/staking/build.gradle.kts @@ -13,6 +13,10 @@ android { namespace = "com.tangem.data.staking" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Core modules */ implementation(projects.core.datasource) @@ -64,7 +68,8 @@ dependencies { // endregion testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) testImplementation(tangemDeps.card.core) diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2.kt new file mode 100644 index 0000000000..89fd57cdb6 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2.kt @@ -0,0 +1,210 @@ +package com.tangem.data.staking.multi + +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 arrow.core.toOption +import com.tangem.data.common.api.safeApiCall +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.stakekit.StakeKitApi +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.isMultiCurrency +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import timber.log.Timber +import javax.inject.Inject + +/** + * Default implementation of [MultiYieldBalanceFetcher] + * + * @property userWalletsStore user wallets store + * @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 DefaultMultiYieldBalanceFetcherV2 @Inject constructor( + private val userWalletsStore: UserWalletsStore, + private val stakingYieldsStore: StakingYieldsStore, + private val yieldsBalancesStore: YieldsBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitApi: StakeKitApi, + private val dispatchers: CoroutineDispatcherProvider, +) { + + suspend fun invoke(params: MultiYieldBalanceFetcher.Params): Either { + checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) { + return it.left() + } + + val stakingIds = getStakingIds(params).getOrElse { + return it.left() + } + + return Either.catchOn(dispatchers.default) { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = stakingIds) + + val availableStakingIds = getAvailableStakingIds( + userWalletId = params.userWalletId, + stakingIds = stakingIds, + ) + + fetch(params = params, stakingIds = availableStakingIds) + } + .onLeft { + Timber.e(it, "Unable to fetch yield balances $params") + + yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) + } + } + + private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) { + val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption() + + val isSupportedByWallet = maybeUserWallet.isSome(UserWallet::isMultiCurrency) + + if (!isSupportedByWallet) { + val exception = IllegalStateException("Wallet $userWalletId is not supported: $maybeUserWallet") + Timber.e(exception) + + ifNotSupported(exception) + } + } + + private suspend fun getStakingIds(params: MultiYieldBalanceFetcher.Params) = either { + val stakingIds = catch( + block = { + params.currencyIdWithNetworkMap.flatMapTo(hashSetOf()) { (currencyId, network) -> + stakingIdFactory.create( + userWalletId = params.userWalletId, + currencyId = currencyId, + network = network, + ) + } + }, + catch = ::raise, + ) + + 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 getAvailableStakingIds(userWalletId: UserWalletId, stakingIds: Set): Set { + val yieldIds = getYieldsIds(userWalletId = userWalletId) + + // [true] -> available + // [false] -> unavailable + val groupedStakingIds = stakingIds.groupBy { stakingId -> + yieldIds.any { it == stakingId.integrationId } + } + + val availableStakingIds = groupedStakingIds[true].orEmpty() + val unavailableStakingIds = groupedStakingIds[false].orEmpty() + + if (unavailableStakingIds.isNotEmpty()) { + yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet()) + } + + return availableStakingIds.toSet().ifEmpty { + val exception = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${stakingIds.joinToString()} + """.trimIndent(), + ) + Timber.d(exception) + throw exception + } + } + + private suspend fun getYieldsIds(userWalletId: UserWalletId): Set { + val yieldsIds = stakingYieldsStore.getSyncWithTimeout().orEmpty() + .mapNotNullTo(destination = hashSetOf(), transform = YieldDTO::id) + + if (yieldsIds.isEmpty()) { + val exception = IllegalStateException("No enabled yields for $userWalletId") + Timber.e(exception) + + throw exception + } + + return yieldsIds + } + + suspend fun fetch(params: MultiYieldBalanceFetcher.Params, stakingIds: Set) { + safeApiCall( + call = { + val requests = stakingIds.map(YieldBalanceRequestBodyFactory::create) + + val yieldBalances = coroutineScope { + requests + .chunked(size = 16) + .map { + async(dispatchers.io) { stakeKitApi.getMultipleYieldBalances(it).bind() } + } + .awaitAll() + .flatten() + .toSet() + } + + yieldsBalancesStore.storeActual(userWalletId = params.userWalletId, values = yieldBalances) + + if (!allResponsesReceived(requests, yieldBalances)) { + val values = stakingIds.filter { stakingId -> + yieldBalances.none { + stakingId.integrationId == it.integrationId && + stakingId.address == it.addresses.address + } + } + + yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = values.toSet()) + } + }, + onError = { + Timber.e(it, "Unable to fetch yield balances $params") + + yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) + + throw it + }, + ) + } + + private fun allResponsesReceived( + requests: List, + yieldBalances: Set, + ): Boolean { + return requests.all { request -> + yieldBalances.any { + request.integrationId == it.integrationId && + request.addresses.address == it.addresses.address + } + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2.kt b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2.kt new file mode 100644 index 0000000000..03b04083c1 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2.kt @@ -0,0 +1,30 @@ +package com.tangem.data.staking.single + +import arrow.core.Either +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.staking.single.SingleYieldBalanceFetcher +import javax.inject.Inject + +/** + * Default implementation of [MultiYieldBalanceFetcher] + * + * @property multiYieldBalanceFetcher multi yield balance fetcher + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceFetcherV2 @Inject constructor( + private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, +) { + + suspend fun invoke(params: SingleYieldBalanceFetcher.Params): Either { + return multiYieldBalanceFetcher( + params = YieldBalanceFetcherParams.Multi( + userWalletId = params.userWalletId, + currencyIdWithNetworkMap = mapOf( + params.currencyId to params.network, + ), + ), + ) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2Test.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2Test.kt new file mode 100644 index 0000000000..4899543e29 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2Test.kt @@ -0,0 +1,470 @@ +package com.tangem.data.staking.multi + +import arrow.core.toOption +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.common.test.domain.wallet.MockUserWalletFactory +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.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultMultiYieldBalanceFetcherV2Test { + + private val userWalletsStore: UserWalletsStore = mockk() + private val stakingYieldsStore: StakingYieldsStore = mockk() + private val yieldsBalancesStore: YieldsBalancesStore = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + private val stakeKitApi: StakeKitApi = mockk() + + private val fetcher = DefaultMultiYieldBalanceFetcherV2( + userWalletsStore = userWalletsStore, + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun resetMocks() { + clearMocks(userWalletsStore, stakingYieldsStore, yieldsBalancesStore, stakingIdFactory, stakeKitApi) + } + + @Test + fun `fetch yields balances successfully`() = runTest { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + 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 + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 successfully if one of stakingIds is unavailable`() = runTest { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + 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)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + + coEvery { yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId)) } just Runs + + val requests = listOf(YieldBalanceRequestBodyFactory.create(tonId)) + val result = setOf(MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId)) + + coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns ApiResponse.Success(result) + coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) } just Runs + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 = userWalletId, stakingIds = setOf(solanaId)) + stakeKitApi.getMultipleYieldBalances(requests) + yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch yields balances failure if user wallet is not supported`() = runTest { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false) + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + coVerify(inverse = true) { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + 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("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}") + + 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 userWalletsStore returns null`() = runTest { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns null + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + coVerify(inverse = true) { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + 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("Wallet ${params.userWalletId} is not supported: ${null.toOption()}") + + 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 stakingIdFactory returns empty list`() = runTest { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns emptySet() + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns emptySet() + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + 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 + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + 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 + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + 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 + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 available yields does not contain ids from params`() = runTest { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + 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 + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 { + // Arrange + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap) + + coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + 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 + + // Actual + val actual = fetcher.invoke(params) + + // Assert + coVerify { + userWalletsStore.getSyncOrNull(params.userWalletId) + 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 userWallet = MockUserWalletFactory.create() + + 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/single/DefaultSingleYieldBalanceFetcherV2Test.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2Test.kt new file mode 100644 index 0000000000..0455f8bc2b --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2Test.kt @@ -0,0 +1,95 @@ +package com.tangem.data.staking.single + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.staking.single.SingleYieldBalanceFetcher +import com.tangem.domain.wallets.models.UserWalletId +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultSingleYieldBalanceFetcherV2Test { + + private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher = mockk() + + private val fetcher = DefaultSingleYieldBalanceFetcherV2( + multiYieldBalanceFetcher = multiYieldBalanceFetcher, + ) + + @BeforeEach + fun resetMocks() { + clearMocks(multiYieldBalanceFetcher) + } + + @Test + fun `fetch yield balance successfully`() = runTest { + // Arrange + val params = SingleYieldBalanceFetcher.Params( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + val multiParams = YieldBalanceFetcherParams.Multi( + userWalletId = userWalletId, + currencyIdWithNetworkMap = mapOf(ton.id to ton.network), + ) + + val multiResult = Unit.right() + + coEvery { multiYieldBalanceFetcher(params = multiParams) } returns multiResult + + // Act + val actual = fetcher.invoke(params).isRight() + + // Assert + Truth.assertThat(actual).isTrue() + + coVerify { multiYieldBalanceFetcher(params = multiParams) } + } + + @Test + fun `fetch yield balance failure`() = runTest { + // Arrange + val params = SingleYieldBalanceFetcher.Params( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + val multiParams = YieldBalanceFetcherParams.Multi( + userWalletId = userWalletId, + currencyIdWithNetworkMap = mapOf(ton.id to ton.network), + ) + + val multiResult = IllegalStateException().left() + + coEvery { multiYieldBalanceFetcher(params = multiParams) } returns multiResult + + // Act + val actual = fetcher.invoke(params) + + // Assert + Truth.assertThat(actual).isEqualTo(multiResult) + coVerify { multiYieldBalanceFetcher(params = multiParams) } + } + + private companion object { + val userWalletId = UserWalletId("011") + val ton = MockCryptoCurrencyFactory().createCoin(Blockchain.TON) + } +} \ 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 index a189e8bcc9..70d96d7b6e 100644 --- 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 @@ -1,11 +1,26 @@ package com.tangem.domain.staking.multi import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.wallets.models.UserWalletId /** * Fetcher of yields balances * [REDACTED_AUTHOR] */ -interface MultiYieldBalanceFetcher : FlowFetcher \ No newline at end of file +interface MultiYieldBalanceFetcher : FlowFetcher { + + /** + * Params for fetching multiple yield balances + * + * @property userWalletId user wallet ID + * @property currencyIdWithNetworkMap map of currency ID to network + */ + data class Params( + val userWalletId: UserWalletId, + val currencyIdWithNetworkMap: Map, + ) +} \ No newline at end of file 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 index 1b81d24be3..603ab4c73a 100644 --- 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 @@ -1,11 +1,28 @@ package com.tangem.domain.staking.single import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.wallets.models.UserWalletId /** * Fetcher of yield balance * [REDACTED_AUTHOR] */ -interface SingleYieldBalanceFetcher : FlowFetcher \ No newline at end of file +interface SingleYieldBalanceFetcher : FlowFetcher { + + /** + * Params for fetching single yield balance + * + * @property userWalletId user wallet ID + * @property currencyId currency ID + * @property network network + */ + data class Params( + val userWalletId: UserWalletId, + val currencyId: CryptoCurrency.ID, + val network: Network, + ) +} \ No newline at end of file From ad4e7e78e96543e13ef5183a8a2eb7c3aa97d271 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Jun 2025 12:49:16 +0500 Subject: [PATCH 09/10] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 4 +- .../tokenlist/MarketsTokenListComponent.kt | 13 ++++++- .../impl/DefaultMarketsEntryComponent.kt | 22 +++++++++++ .../entry/impl/MarketsEntryChildFactory.kt | 11 +++++- .../entry/impl/ui/EntryBottomSheetContent.kt | 6 +-- .../impl/DefaultMarketsTokenListComponent.kt | 39 ++++++++++++------- .../tokenlist/impl/di/ComponentModule.kt | 25 ++++++++++-- 7 files changed, 96 insertions(+), 24 deletions(-) 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 0657cb5dab..7008e3f4ec 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 @@ -3,10 +3,10 @@ package com.tangem.tap.routing.utils import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.context.AppComponentContext import com.tangem.domain.qrscanning.models.SourceType -import com.tangem.feature.usedesk.api.UsedeskComponent import com.tangem.feature.qrscanning.QrScanningComponent import com.tangem.feature.referral.api.ReferralComponent import com.tangem.feature.stories.api.StoriesComponent +import com.tangem.feature.usedesk.api.UsedeskComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent @@ -49,7 +49,7 @@ internal class ChildFactory @Inject constructor( private val disclaimerComponentFactory: DisclaimerComponent.Factory, private val manageTokensComponentFactory: ManageTokensComponent.Factory, private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, - private val marketsTokenListComponentFactory: MarketsTokenListComponent.Factory, + private val marketsTokenListComponentFactory: MarketsTokenListComponent.FactoryScreen, private val onrampComponentFactory: OnrampComponent.Factory, private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory, private val buyCryptoComponentFactory: BuyCryptoComponent.Factory, diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/tokenlist/MarketsTokenListComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/tokenlist/MarketsTokenListComponent.kt index 670816199f..5a5179c1ef 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/tokenlist/MarketsTokenListComponent.kt +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/tokenlist/MarketsTokenListComponent.kt @@ -5,8 +5,11 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.markets.entry.BottomSheetState @Stable @@ -19,5 +22,13 @@ interface MarketsTokenListComponent : ComposableContentComponent { modifier: Modifier, ) - interface Factory : ComponentFactory + interface FactoryScreen : ComponentFactory + + interface FactoryBottomSheet { + fun create( + context: AppComponentContext, + params: Unit, + onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)?, + ): MarketsTokenListComponent + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt index da862ba234..adc1968208 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.StackNavigation @@ -14,6 +15,9 @@ import com.arkivanov.decompose.value.Value 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.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.entry.MarketsEntryComponent import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory.Child @@ -48,6 +52,7 @@ internal class DefaultMarketsEntryComponent @AssistedInject constructor( componentContext = factoryContext, router = innerRouter, ), + onTokenClick = ::marketsListTokenSelected, ) }, ) @@ -67,6 +72,23 @@ internal class DefaultMarketsEntryComponent @AssistedInject constructor( ) } + @OptIn(ExperimentalDecomposeApi::class) + private fun marketsListTokenSelected(token: TokenMarketParams, appCurrency: AppCurrency) { + innerRouter.push( + route = Child.TokenDetails( + params = MarketsTokenDetailsComponent.Params( + token = token, + appCurrency = appCurrency, + showPortfolio = true, + analyticsParams = MarketsTokenDetailsComponent.AnalyticsParams( + blockchain = null, + source = "Market", + ), + ), + ), + ) + } + private fun onChildBack() { if (stack.value.active.configuration !is Child.TokenList) { stackNavigation.popWhile { it != Child.TokenList } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt index 9a11de132d..785ef74cdb 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt @@ -3,13 +3,15 @@ package com.tangem.features.markets.entry.impl import androidx.compose.runtime.Immutable import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.tokenlist.MarketsTokenListComponent import kotlinx.serialization.Serializable import javax.inject.Inject internal class MarketsEntryChildFactory @Inject constructor( - private val tokenListComponentFactory: MarketsTokenListComponent.Factory, + private val tokenListComponentFactory: MarketsTokenListComponent.FactoryBottomSheet, private val tokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, ) { @@ -26,7 +28,11 @@ internal class MarketsEntryChildFactory @Inject constructor( data class TokenDetails(val params: MarketsTokenDetailsComponent.Params) : Child } - fun createChild(child: Child, appComponentContext: AppComponentContext): Any { + fun createChild( + child: Child, + appComponentContext: AppComponentContext, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): Any { return when (child) { is Child.TokenDetails -> { tokenDetailsComponentFactory.create( @@ -38,6 +44,7 @@ internal class MarketsEntryChildFactory @Inject constructor( tokenListComponentFactory.create( context = appComponentContext, params = Unit, + onTokenClick = onTokenClick, ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt index 5d6ff14c2a..9c72accb3a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt @@ -46,7 +46,7 @@ internal fun EntryBottomSheetContent( modifier = modifier, ) } - MarketsEntryChildFactory.Child.TokenList -> { + is MarketsEntryChildFactory.Child.TokenList -> { (it.instance as MarketsTokenListComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, @@ -84,7 +84,7 @@ private fun BackgroundColorEffects( animationSpec = tween(durationMillis = 500), ) } - MarketsEntryChildFactory.Child.TokenList -> { + is MarketsEntryChildFactory.Child.TokenList -> { backgroundColor.animateTo( primary, animationSpec = tween(durationMillis = 500), @@ -119,7 +119,7 @@ private fun BackgroundColorEffects( is MarketsEntryChildFactory.Child.TokenDetails -> { backgroundColor.snapTo(tertiary) } - MarketsEntryChildFactory.Child.TokenList -> { + is MarketsEntryChildFactory.Child.TokenList -> { backgroundColor.snapTo(primary) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt index 16ecb9a9c7..76418cb24f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt @@ -18,6 +18,8 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.markets.toSerializableParam import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.tokenlist.MarketsTokenListComponent @@ -31,9 +33,10 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @Suppress("UnusedPrivateMember") -class DefaultMarketsTokenListComponent @AssistedInject constructor( +internal class DefaultMarketsTokenListComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: Unit, + @Assisted onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)? = null, ) : AppComponentContext by appComponentContext, MarketsTokenListComponent { private val model: MarketsListModel = getOrCreateModel() @@ -41,17 +44,23 @@ class DefaultMarketsTokenListComponent @AssistedInject constructor( init { model.tokenSelected .onEach { (token, appCurrency) -> - router.push( - AppRoute.MarketsTokenDetails( - token = token.toSerializableParam(), - appCurrency = appCurrency, - showPortfolio = true, - analyticsParams = AnalyticsParams( - blockchain = null, - source = "Market", + // If specific routing call back is provided, invoke it (for example inner routing in Bottom Sheet) + // Otherwise navigate using app routing + if (onTokenClick != null) { + onTokenClick(token.toSerializableParam(), appCurrency) + } else { + router.push( + AppRoute.MarketsTokenDetails( + token = token.toSerializableParam(), + appCurrency = appCurrency, + showPortfolio = true, + analyticsParams = AnalyticsParams( + blockchain = null, + source = "Market", + ), ), - ), - ) + ) + } } .launchIn(componentScope) } @@ -112,7 +121,11 @@ class DefaultMarketsTokenListComponent @AssistedInject constructor( } @AssistedFactory - interface Factory : MarketsTokenListComponent.Factory { - override fun create(context: AppComponentContext, params: Unit): DefaultMarketsTokenListComponent + interface FactoryBottomSheet : MarketsTokenListComponent.FactoryBottomSheet { + override fun create( + context: AppComponentContext, + params: Unit, + onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)?, + ): DefaultMarketsTokenListComponent } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt index 4befeceeea..2c1ad7bb23 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt @@ -1,9 +1,11 @@ package com.tangem.features.markets.tokenlist.impl.di +import com.tangem.core.decompose.context.AppComponentContext import com.tangem.features.markets.tokenlist.MarketsTokenListComponent import com.tangem.features.markets.tokenlist.impl.DefaultMarketsTokenListComponent import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -14,7 +16,24 @@ internal interface ComponentModule { @Binds @Singleton - fun bindMarketsTokenListComponent( - factory: DefaultMarketsTokenListComponent.Factory, - ): MarketsTokenListComponent.Factory + fun bindMarketsTokenListBottomSheetComponent( + factory: DefaultMarketsTokenListComponent.FactoryBottomSheet, + ): MarketsTokenListComponent.FactoryBottomSheet +} + +@Module +@InstallIn(SingletonComponent::class) +internal class ComponentProvideModule { + + @Provides + @Singleton + fun providesMarketsTokenListScreenComponent( + factory: DefaultMarketsTokenListComponent.FactoryBottomSheet, + ): MarketsTokenListComponent.FactoryScreen { + return object : MarketsTokenListComponent.FactoryScreen { + override fun create(context: AppComponentContext, params: Unit): MarketsTokenListComponent { + return factory.create(context, params, null) + } + } + } } \ No newline at end of file From b808b4561d3e89755d204e17c142b85b2e796e5a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Jun 2025 07:11:27 +0000 Subject: [PATCH 10/10] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 31f29bcef0..14daca0245 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.25.0-1082" +tangemBlockchainSdk = "develop-1081" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.25.0-481" +tangemCardSdk = "develop-482" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^