Updated on 2026-08-14
This commit is contained in:
commit
b654c6ea6e
35 changed files with 1279 additions and 165 deletions
|
|
@ -139,7 +139,7 @@ sealed class QuickActionUM(
|
|||
)
|
||||
|
||||
data object SwapAndSend : V2(
|
||||
title = resourceReference(R.string.common_send_with_swap),
|
||||
title = resourceReference(R.string.send_with_swap_confirm_title),
|
||||
description = resourceReference(R.string.quick_action_send_and_swap_description),
|
||||
icon = R.drawable.ic_exchange_mini_24,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ data class TokenActionsBSContentUM(
|
|||
iconRes = R.drawable.ic_exchange_horizontal_24,
|
||||
),
|
||||
SendWithSwap(
|
||||
text = resourceReference(R.string.common_send_with_swap),
|
||||
text = resourceReference(R.string.send_with_swap_confirm_title),
|
||||
iconRes = R.drawable.ic_exchange_horizontal_24,
|
||||
),
|
||||
Stake(
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ internal class DefaultNFTRuntimeStore(
|
|||
},
|
||||
)
|
||||
}
|
||||
val assetsCount = when (assets) {
|
||||
is NFTCollection.Assets.Value -> assets.items.size
|
||||
val assetsCount = when {
|
||||
assets is NFTCollection.Assets.Value && assets.items.isNotEmpty() -> assets.items.size
|
||||
else -> data.count
|
||||
}
|
||||
data.copy(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.datasource.local.nft
|
|||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.blockchain.nft.models.NFTCollection
|
||||
|
|
@ -58,6 +59,7 @@ class NFTPersistenceStoreFactory @Inject constructor(
|
|||
types = types,
|
||||
defaultValue = defaultValue,
|
||||
),
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { defaultValue },
|
||||
produceFile = { context.dataStoreFile(fileName = fileName) },
|
||||
scope = appScope,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
package com.tangem.datasource.local.nft
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.nft.models.NFTAsset
|
||||
import com.tangem.domain.nft.models.NFTCollection
|
||||
import com.tangem.domain.nft.models.NFTCollections
|
||||
import com.tangem.domain.nft.models.NFTSalePrice
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigInteger
|
||||
|
||||
class DefaultNFTRuntimeStoreTest {
|
||||
|
||||
private val network = createNetwork()
|
||||
|
||||
private val store = DefaultNFTRuntimeStore(
|
||||
network = network,
|
||||
collectionsRuntimeStore = RuntimeSharedStore(),
|
||||
pricesRuntimeStore = RuntimeSharedStore(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN collection with empty loaded assets WHEN getCollections THEN collection is kept`() = runTest {
|
||||
// Arrange
|
||||
val collection = createCollection(
|
||||
count = 1,
|
||||
assets = NFTCollection.Assets.Value(items = emptyList(), source = StatusSource.ACTUAL),
|
||||
)
|
||||
store.initialize(collections = createCollections(collection), prices = emptyMap())
|
||||
|
||||
// Act
|
||||
val content = store.getCollections().first().content
|
||||
|
||||
// Assert
|
||||
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
|
||||
content as NFTCollections.Content.Collections
|
||||
val actual = content.collections.orEmpty().single()
|
||||
assertThat(actual.count).isEqualTo(1)
|
||||
assertThat(actual.assets).isInstanceOf(NFTCollection.Assets.Value::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN collection with loaded assets WHEN getCollections THEN count recalculated from assets`() = runTest {
|
||||
// Arrange
|
||||
val collection = createCollection(
|
||||
count = 5,
|
||||
assets = NFTCollection.Assets.Value(items = listOf(createAsset()), source = StatusSource.ACTUAL),
|
||||
)
|
||||
store.initialize(collections = createCollections(collection), prices = emptyMap())
|
||||
|
||||
// Act
|
||||
val content = store.getCollections().first().content
|
||||
|
||||
// Assert
|
||||
content as NFTCollections.Content.Collections
|
||||
assertThat(content.collections.orEmpty().single().count).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN collection with zero count and not loaded assets WHEN getCollections THEN collection filtered out`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val collection = createCollection(count = 0, assets = NFTCollection.Assets.Empty)
|
||||
store.initialize(collections = createCollections(collection), prices = emptyMap())
|
||||
|
||||
// Act
|
||||
val content = store.getCollections().first().content
|
||||
|
||||
// Assert
|
||||
content as NFTCollections.Content.Collections
|
||||
assertThat(content.collections.orEmpty()).isEmpty()
|
||||
}
|
||||
|
||||
private fun createCollections(vararg collections: NFTCollection) = NFTCollections(
|
||||
network = network,
|
||||
content = NFTCollections.Content.Collections(
|
||||
collections = collections.toList(),
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
)
|
||||
|
||||
private fun createCollection(count: Int, assets: NFTCollection.Assets) = NFTCollection(
|
||||
id = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
|
||||
network = network,
|
||||
name = "Test collection",
|
||||
description = null,
|
||||
logoUrl = null,
|
||||
count = count,
|
||||
assets = assets,
|
||||
)
|
||||
|
||||
private fun createAsset(): NFTAsset {
|
||||
val assetId = NFTAsset.Identifier.EVM(
|
||||
tokenAddress = TOKEN_ADDRESS,
|
||||
tokenId = BigInteger.ONE,
|
||||
contractType = NFTAsset.Identifier.EVM.ContractType.ERC721,
|
||||
)
|
||||
return NFTAsset(
|
||||
id = assetId,
|
||||
collectionId = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
|
||||
network = network,
|
||||
contractType = "ERC721",
|
||||
owner = null,
|
||||
name = "Test asset",
|
||||
description = null,
|
||||
amount = null,
|
||||
decimals = 0,
|
||||
salePrice = NFTSalePrice.Empty(assetId),
|
||||
rarity = null,
|
||||
media = null,
|
||||
traits = emptyList(),
|
||||
source = StatusSource.ACTUAL,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNetwork() = Network(
|
||||
id = Network.ID(rawId = Network.RawID("ethereum"), derivationPath = Network.DerivationPath.None),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.ERC20,
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001"
|
||||
}
|
||||
}
|
||||
|
|
@ -131,6 +131,7 @@
|
|||
<string name="address_book_no_contacts">Noch keine Kontakte</string>
|
||||
<string name="address_book_no_contacts_description">Die von Ihnen hinzugefügten Kontakte werden hier angezeigt</string>
|
||||
<string name="address_book_remove_address">Adresse entfernen</string>
|
||||
<string name="address_book_save_address">Adresse speichern</string>
|
||||
<string name="address_book_save_contact">Kontakt speichern</string>
|
||||
<string name="address_book_save_to_wallet_title">In Wallet speichern</string>
|
||||
<string name="address_book_save_wallet_to_description">Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft.</string>
|
||||
|
|
@ -638,6 +639,8 @@
|
|||
<string name="express_exchange_notification_long_transaction_time_title">Lange Transaktionszeit</string>
|
||||
<string name="express_exchange_notification_refund_text">Der Transaktionsbetrag wurde aufgrund von OKX- oder Bridge-Regeln in %1$s auf deine Wallet zurückerstattet. %2$s</string>
|
||||
<string name="express_exchange_notification_refund_title">Der Betrag wurde in %1$s (%2$s Netzwerk) zurückerstattet.</string>
|
||||
<string name="express_exchange_notification_refunded_in_text">Ihr Guthaben wurde gemäß den Regeln der OKX-Börse in %1$s an Ihre Wallet im %2$s -Netzwerk zurückerstattet.</string>
|
||||
<string name="express_exchange_notification_refunded_in_title">Rückerstattung erfolgt in %s</string>
|
||||
<string name="express_exchange_notification_verification_text">Besuche die Website des Anbieters zur Überprüfung</string>
|
||||
<string name="express_exchange_notification_verification_title">KYC-Überprüfung durch den Anbieter erforderlich</string>
|
||||
<string name="express_exchange_status_bought">Kauf abgeschlossen</string>
|
||||
|
|
@ -1307,7 +1310,9 @@
|
|||
<string name="promo_campaign_enroll_success_title">Sie sind erfolgreich eingeschrieben in %1$s</string>
|
||||
<string name="promo_campaign_not_active_subtitle">Diese Aktion existiert nicht mehr oder ist abgelaufen.</string>
|
||||
<string name="promo_campaign_not_active_title">Kampagne nicht aktiv</string>
|
||||
<string name="promo_campaign_reactivation_summary_description">Erhalten Sie bei jedem Swap über 500 $ ein Cashback von 0,5% , und zwar für alle Währungspaare außer „Stablecoin zu Stablecoin“. Die maximale Auszahlung beträgt 50 $ pro Swap.\n\nFühren Sie fünf qualifizierende Swaps durch und sichern Sie sich einen zusätzlichen Bonus von 10 $.\n\nDie Prämien werden wöchentlich in USDT oder USDC an die ausgewählte Adresse ausgezahlt.</string>
|
||||
<string name="promo_campaign_select_cashback_account">Cashback-Konto auswählen</string>
|
||||
<string name="promo_campaign_select_token">Token auswählen</string>
|
||||
<string name="promo_campaign_summary_title">Melden Sie sich an %1$s</string>
|
||||
<string name="promo_campaign_terms_agreement">Ich stimme zu, dass %1$s</string>
|
||||
<string name="promo_campaign_terms_agreement_android">Ich stimme zu, dass</string>
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@
|
|||
<string name="common_action_failed">%s falló</string>
|
||||
<string name="common_activate">Activar</string>
|
||||
<string name="common_add">Agregar</string>
|
||||
<string name="common_add_funds">Agregar fondos</string>
|
||||
<string name="common_add_funds">Depositar</string>
|
||||
<string name="common_add_to_portfolio">Añadir al portafolio</string>
|
||||
<string name="common_add_token">Agregar token</string>
|
||||
<string name="common_add_tokens">Añada tokens</string>
|
||||
|
|
@ -347,8 +347,9 @@
|
|||
<string name="common_from">De</string>
|
||||
<string name="common_from_wallet_name">De %s</string>
|
||||
<string name="common_generate_addresses">Sincronizar direcciones</string>
|
||||
<string name="common_get">Comprar</string>
|
||||
<string name="common_get_started">Comenzar</string>
|
||||
<string name="common_get_token">Obtener token</string>
|
||||
<string name="common_get_token">Comprar token</string>
|
||||
<string name="common_go_to_provider">Ir al proveedor</string>
|
||||
<string name="common_go_to_token">Ir al token</string>
|
||||
<string name="common_got_it">Entendido</string>
|
||||
|
|
@ -1241,6 +1242,8 @@
|
|||
<string name="onramp_title_available_from">Disponible desde</string>
|
||||
<string name="onramp_title_available_up_to">Disponible hasta</string>
|
||||
<string name="onramp_title_you_get">Obtiene</string>
|
||||
<string name="onramp_token_is_not_supported_banner_subtitle">Este token no está soportado. Por favor, elija otro token para comprar.</string>
|
||||
<string name="onramp_token_is_not_supported_banner_title">%s no está soportado</string>
|
||||
<string name="onramp_tos_external_providers">El servicio es proporcionado por un proveedor externo. \nTangem no es responsable.</string>
|
||||
<string name="onramp_transaction_status_footer_text">Puede comprobar el estado de la transacción desde la página detallada del token</string>
|
||||
<string name="onramp_up_to_rate">Hasta</string>
|
||||
|
|
@ -2505,7 +2508,7 @@
|
|||
<string name="yield_module_approve_sheet_fee_note">Se le descontará la comisión y se volverán a suministrar sus activos.</string>
|
||||
<string name="yield_module_approve_sheet_subtitle">Para seguir generando rendimiento, se requiere aprobación.</string>
|
||||
<string name="yield_module_approve_sheet_title">Confirmar aprobación</string>
|
||||
<string name="yield_module_average_apy">APY promedio %1$s%%</string>
|
||||
<string name="yield_module_average_apy">APY actual %1$s%%</string>
|
||||
<string name="yield_module_balance_info_sheet_subtitle">Sus fondos se suministran actualmente al protocolo Aave, pero puede gestionarlos en cualquier momento.</string>
|
||||
<string name="yield_module_balance_info_sheet_title">Su %s está depositado en Aave</string>
|
||||
<string name="yield_module_chart_loading_error">No se puede cargar el gráfico...</string>
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@
|
|||
<string name="common_from">送信元</string>
|
||||
<string name="common_from_wallet_name">%sから</string>
|
||||
<string name="common_generate_addresses">アドレスを同期する</string>
|
||||
<string name="common_get">入手</string>
|
||||
<string name="common_get_started">はじめる</string>
|
||||
<string name="common_get_token">トークンを取得</string>
|
||||
<string name="common_go_to_provider">プロバイダーへ移動</string>
|
||||
|
|
@ -1249,6 +1250,20 @@
|
|||
<string name="organize_tokens_sort_by_balance">残高順</string>
|
||||
<string name="organize_tokens_title">トークンを整理する</string>
|
||||
<string name="organize_tokens_ungroup">グループ解除</string>
|
||||
<string name="promo_campaign_already_activated_subtitle">対象のキャッシュバックは次のアドレスに配布されます:</string>
|
||||
<string name="promo_campaign_already_activated_title">すでに%1$sに参加しています</string>
|
||||
<string name="promo_campaign_eligible_tokens">対象トークン</string>
|
||||
<string name="promo_campaign_enroll">参加する</string>
|
||||
<string name="promo_campaign_enroll_success_title">%1$sへの参加が完了しました</string>
|
||||
<string name="promo_campaign_not_active_subtitle">このキャンペーンは終了したか、無効です</string>
|
||||
<string name="promo_campaign_not_active_title">キャンペーンは実施されていません</string>
|
||||
<string name="promo_campaign_reactivation_summary_description">500ドルを超えるスワップごとに0.5%のキャッシュバックを獲得できます。ステーブルコイン同士を除く、すべてのペアが対象です。キャッシュバックは1回のスワップあたり最大50ドルです。\n\n対象のスワップを5回完了すると、さらに10ドルのボーナスを獲得できます。\n\n報酬は毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。</string>
|
||||
<string name="promo_campaign_select_cashback_account">キャッシュバックの受取口座を選択</string>
|
||||
<string name="promo_campaign_select_token">トークンを選択</string>
|
||||
<string name="promo_campaign_summary_title">%1$sに参加</string>
|
||||
<string name="promo_campaign_terms_agreement">%1$sに同意します</string>
|
||||
<string name="promo_campaign_terms_link">%1$s 利用規約</string>
|
||||
<string name="promo_campaign_whale_swap_summary_description">7月末まで、累計1万ドル以上のスワップでキャッシュバックを獲得できます。\n\n累計スワップ額に応じて還元率がアップします。1万ドル以上で0.10%、2万ドル以上で0.20%、10万ドル以上で0.50%。\n\nキャッシュバックは、1回のスワップあたり最大500ドル、ウォレットごと・スワップ方向ごとにキャンペーン期間中、合計最大1万ドルです。ステーブルコイン同士のスワップは対象外です。\n\nキャッシュバックは毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。</string>
|
||||
<string name="provider_name_support">%sサポート</string>
|
||||
<string name="push_notification_settings_banner_description">プッシュ通知は有効ですが、許可するまで動作しません</string>
|
||||
<string name="push_notification_settings_banner_title">通知を許可する</string>
|
||||
|
|
|
|||
|
|
@ -377,6 +377,7 @@
|
|||
<string name="common_from">Из</string>
|
||||
<string name="common_from_wallet_name">Из %s</string>
|
||||
<string name="common_generate_addresses">Синхронизировать адреса</string>
|
||||
<string name="common_get">Приобрести</string>
|
||||
<string name="common_get_started">Начать</string>
|
||||
<string name="common_get_token">Получить токен</string>
|
||||
<string name="common_go_to_provider">К провайдеру</string>
|
||||
|
|
@ -2010,6 +2011,7 @@
|
|||
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Внесите USDC на счёт, чтобы покрыть комиссию</string>
|
||||
<string name="tangempay_reissue_card_insufficient_funds_title">Невозможно покрыть комиссию</string>
|
||||
<string name="tangempay_reissue_card_title">Перевыпустить карту?</string>
|
||||
<string name="tangempay_remove_account">Удалить аккаунт</string>
|
||||
<string name="tangempay_service_unavailable_description">Мы устраняем техническую проблему. Пожалуйста, попробуйте позже.</string>
|
||||
<string name="tangempay_service_unavailable_title">Сервис временно недоступен</string>
|
||||
<string name="tangempay_service_unreachable_try_later">Не можем показать данные карты, но оплаты продолжают работать.</string>
|
||||
|
|
@ -2519,7 +2521,7 @@
|
|||
<string name="yield_module_approve_sheet_fee_note">Комиссия будет списана, и ваши активы снова начнут приносить доход.</string>
|
||||
<string name="yield_module_approve_sheet_subtitle">Чтобы продолжить зарабатывать, нужно выдать разрешение.</string>
|
||||
<string name="yield_module_approve_sheet_title">Подтвердить разрешение</string>
|
||||
<string name="yield_module_average_apy">Средний APY %1$s%%</string>
|
||||
<string name="yield_module_average_apy">Текущий APY %1$s%%</string>
|
||||
<string name="yield_module_balance_info_sheet_subtitle">Ваши средства в данный момент размещены в протоколе Aave, но вы можете воспользоваться ими в любое время.</string>
|
||||
<string name="yield_module_balance_info_sheet_title">Ваш %s внесён в Aave</string>
|
||||
<string name="yield_module_chart_loading_error">Невозможно загрузить график</string>
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@
|
|||
<string name="common_from">从</string>
|
||||
<string name="common_from_wallet_name">从 %s</string>
|
||||
<string name="common_generate_addresses">同步地址</string>
|
||||
<string name="common_get">得到</string>
|
||||
<string name="common_get_started">开始</string>
|
||||
<string name="common_get_token">获取代币</string>
|
||||
<string name="common_go_to_provider">前往服务提供商</string>
|
||||
|
|
@ -1245,6 +1246,21 @@
|
|||
<string name="organize_tokens_sort_by_balance">按余额</string>
|
||||
<string name="organize_tokens_title">整理代币</string>
|
||||
<string name="organize_tokens_ungroup">取消分组</string>
|
||||
<string name="promo_campaign_already_activated_subtitle">符合条件的返现将发放给:</string>
|
||||
<string name="promo_campaign_already_activated_title">您已经注册了 %1$s</string>
|
||||
<string name="promo_campaign_eligible_tokens">符合条件的代币</string>
|
||||
<string name="promo_campaign_enroll">注册</string>
|
||||
<string name="promo_campaign_enroll_success_title">您已成功注册 %1$s</string>
|
||||
<string name="promo_campaign_not_active_subtitle">此活动已结束或已过期。</string>
|
||||
<string name="promo_campaign_not_active_title">活动未启动</string>
|
||||
<string name="promo_campaign_reactivation_summary_description">每次兑换金额超过 500 美元,即可获得 0.5% 的返现,适用于除稳定币与稳定币之间的兑换以外的所有交易对。每次兑换的最高返现金额为 50 美元。\n\n完成五笔符合条件的兑换,即可解锁额外 10 美元的奖励。\n\n奖励每周以 USDT 或 USDC 形式支付至您选择的地址。</string>
|
||||
<string name="promo_campaign_select_cashback_account">选择返现账户</string>
|
||||
<string name="promo_campaign_select_token">选择代币</string>
|
||||
<string name="promo_campaign_summary_title">报名参加 %1$s</string>
|
||||
<string name="promo_campaign_terms_agreement">我同意 %1$s</string>
|
||||
<string name="promo_campaign_terms_agreement_android">我同意</string>
|
||||
<string name="promo_campaign_terms_link">%1$s 条款</string>
|
||||
<string name="promo_campaign_whale_swap_summary_description">7月底前,每次1万美元及以上的兑换均可获得返现。\n\n返现率随交易金额递增:1万美元起为0.10% ,2万美元起为0.20% ,10万美元起为0.50% 。n\n最高奖励:每笔最少兑换500美元,且在活动期间内,每个钱包单向兑换方向的奖励上限为10,000美元。稳定币与稳定币之间的兑换不计入奖励范围。\n\n奖励每周发放至您指定的USDT或USDC地址。</string>
|
||||
<string name="provider_name_support">%s 支持</string>
|
||||
<string name="push_notification_settings_banner_description">推送通知已启用,但需要您在设备设置中允许通知才能正常工作。</string>
|
||||
<string name="push_notification_settings_banner_title">允许通知</string>
|
||||
|
|
|
|||
|
|
@ -928,6 +928,7 @@
|
|||
<string name="markets_portfolio_block_subtitle">In your portfolio</string>
|
||||
<string name="markets_portfolio_block_title">Your portfolio</string>
|
||||
<string name="markets_portfolio_block_token_unsupported">**Token not supported**. This token is currently not supported in the wallet</string>
|
||||
<string name="markets_portfolio_eligible_block_title">Other eligible tokens</string>
|
||||
<string name="markets_pulse_common_title">Market Pulse</string>
|
||||
<string name="markets_quick_actions">Quick actions</string>
|
||||
<string name="markets_search_clear_all_hints">Clear all</string>
|
||||
|
|
@ -2624,7 +2625,7 @@
|
|||
<string name="yield_module_approve_sheet_fee_note">The fee will be deducted, and your assets will be resupplied.</string>
|
||||
<string name="yield_module_approve_sheet_subtitle">To continue generating yield, approval is required.</string>
|
||||
<string name="yield_module_approve_sheet_title">Confirm approval</string>
|
||||
<string name="yield_module_average_apy">Average APY %1$s%%</string>
|
||||
<string name="yield_module_average_apy">Current APY %1$s%%</string>
|
||||
<string name="yield_module_balance_info_sheet_subtitle">Your funds are currently supplied to the Aave protocol, but you can manage them at any time.</string>
|
||||
<string name="yield_module_balance_info_sheet_title">Your %s is supplied to Aave</string>
|
||||
<string name="yield_module_chart_loading_error">Unable to load chart...</string>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ fun TangemBottomSheetDraggableHeader() {
|
|||
height = TangemTheme.dimens2.x1,
|
||||
)
|
||||
.background(
|
||||
color = TangemTheme.colors2.graphic.neutral.primaryInverted,
|
||||
color = TangemTheme.colors3.icon.tertiary,
|
||||
shape = RoundedCornerShape(TangemTheme.dimens2.x0_5),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ dependencies {
|
|||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import com.tangem.datasource.local.nft.NFTPersistenceStore
|
|||
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
|
||||
import com.tangem.datasource.local.nft.NFTRuntimeStore
|
||||
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
|
||||
import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter
|
||||
import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter
|
||||
import com.tangem.datasource.local.nft.converter.NFTSdkAssetSalePriceConverter
|
||||
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter
|
||||
|
|
@ -181,29 +182,27 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
getNFTPersistenceStore(userWalletId, network)
|
||||
.getCollectionsSync()
|
||||
?.map { collection ->
|
||||
if (collection.identifier == sdkCollectionId) {
|
||||
collection.copy(assets = assets)
|
||||
} else {
|
||||
collection
|
||||
}
|
||||
}
|
||||
?.let { collections ->
|
||||
saveCollectionsInRuntime(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
collections = collections,
|
||||
)
|
||||
saveCollectionsInPersistence(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
collections = collections,
|
||||
)
|
||||
}
|
||||
saveAssetsInRuntime(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
collectionId = collectionId,
|
||||
assets = assets,
|
||||
)
|
||||
|
||||
// local cache failures must not affect the runtime state which is already up to date
|
||||
runSuspendCatching {
|
||||
updateAssetsInPersistence(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
sdkCollectionId = sdkCollectionId,
|
||||
assets = assets,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
TangemLogger.e("Failed to persist NFT assets for $network", error)
|
||||
}
|
||||
}.onLeft { throwable ->
|
||||
if (throwable !is UnsupportedOperationException) {
|
||||
TangemLogger.e("Failed to refresh NFT assets for $network", throwable)
|
||||
saveFailedStateInRuntime(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
|
|
@ -269,18 +268,29 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
expireCollections(userWalletId, network)
|
||||
|
||||
val collections = walletManagersFacade.getNFTCollections(userWalletId, network)
|
||||
val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network)
|
||||
|
||||
// local cache failures must not affect successfully fetched collections
|
||||
val mergedCollections = runSuspendCatching {
|
||||
collections.mergeWithStoredAssets(userWalletId, network)
|
||||
}.getOrElse { error ->
|
||||
TangemLogger.e("Failed to merge NFT collections with stored assets for $network", error)
|
||||
collections
|
||||
}
|
||||
|
||||
saveCollectionsInRuntime(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
collections = mergedCollections,
|
||||
)
|
||||
saveCollectionsInPersistence(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
collections = mergedCollections,
|
||||
)
|
||||
runSuspendCatching {
|
||||
saveCollectionsInPersistence(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
collections = mergedCollections,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
TangemLogger.e("Failed to persist NFT collections for $network", error)
|
||||
}
|
||||
|
||||
if (refreshAssets) {
|
||||
mergedCollections.forEach { collection ->
|
||||
|
|
@ -292,6 +302,7 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
}.onLeft { throwable ->
|
||||
TangemLogger.e("Failed to refresh NFT collections for $network", throwable)
|
||||
saveFailedStateInRuntime(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
|
|
@ -391,12 +402,72 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun saveAssetsInRuntime(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
collectionId: NFTCollection.Identifier,
|
||||
assets: List<SdkNFTAsset>,
|
||||
) {
|
||||
val store = getNFTRuntimeStore(userWalletId, network)
|
||||
val storedCollections = store.getCollectionsSync()
|
||||
val content = storedCollections.content as? NFTCollections.Content.Collections ?: return
|
||||
|
||||
val convertedAssets = assets
|
||||
.map { asset -> NFTSdkAssetConverter.convert(network to asset) }
|
||||
.filter { it.id !is NFTAsset.Identifier.Unknown }
|
||||
|
||||
val updatedCollections = content.collections
|
||||
?.map { collection ->
|
||||
if (collection.id == collectionId) {
|
||||
collection.copy(
|
||||
assets = NFTCollection.Assets.Value(
|
||||
items = convertedAssets,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
collection
|
||||
}
|
||||
}
|
||||
|
||||
store.saveCollections(
|
||||
storedCollections.copy(content = content.copy(collections = updatedCollections)),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateAssetsInPersistence(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
sdkCollectionId: SdkNFTCollection.Identifier,
|
||||
assets: List<SdkNFTAsset>,
|
||||
) {
|
||||
val storedCollections = getNFTPersistenceStore(userWalletId, network).getCollectionsSync() ?: return
|
||||
val updatedCollections = storedCollections.map { collection ->
|
||||
if (collection.identifier == sdkCollectionId) {
|
||||
collection.copy(assets = assets)
|
||||
} else {
|
||||
collection
|
||||
}
|
||||
}
|
||||
saveCollectionsInPersistence(userWalletId, network, updatedCollections)
|
||||
}
|
||||
|
||||
private suspend fun saveCollectionsInPersistence(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
collections: List<SdkNFTCollection>,
|
||||
) {
|
||||
getNFTPersistenceStore(userWalletId, network).saveCollections(collections)
|
||||
val serializableCollections = collections
|
||||
.filter { it.identifier !is SdkNFTCollection.Identifier.Unknown }
|
||||
.map { collection ->
|
||||
collection.copy(
|
||||
assets = collection.assets.filter { asset ->
|
||||
asset.identifier !is SdkNFTAsset.Identifier.Unknown &&
|
||||
asset.collectionIdentifier !is SdkNFTCollection.Identifier.Unknown
|
||||
},
|
||||
)
|
||||
}
|
||||
getNFTPersistenceStore(userWalletId, network).saveCollections(serializableCollections)
|
||||
}
|
||||
|
||||
private suspend fun saveSalePriceInRuntime(userWalletId: UserWalletId, network: Network, salePrice: NFTSalePrice) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
package com.tangem.data.nft
|
||||
|
||||
import android.content.Context
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.datasource.local.nft.NFTPersistenceStore
|
||||
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
|
||||
import com.tangem.datasource.local.nft.NFTRuntimeStore
|
||||
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
|
||||
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.nft.models.NFTAsset
|
||||
import com.tangem.domain.nft.models.NFTCollection
|
||||
import com.tangem.domain.nft.models.NFTCollections
|
||||
import com.tangem.domain.nft.models.NFTSalePrice
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.io.IOException
|
||||
import java.math.BigInteger
|
||||
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
|
||||
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DefaultNFTRepositoryTest {
|
||||
|
||||
private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory = mockk()
|
||||
private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory = mockk()
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val context: Context = mockk()
|
||||
|
||||
private val userWalletId = UserWalletId("011")
|
||||
private val userWallet = mockk<UserWallet.Hot> {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
private val network: Network = MockCryptoCurrencyFactory().ethereum.network
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(nftPersistenceStoreFactory, nftRuntimeStoreFactory, walletManagersFacade, userWalletsListRepository)
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||
every { context.resources } returns mockk()
|
||||
}
|
||||
|
||||
private fun createRepository() = DefaultNFTRepository(
|
||||
nftPersistenceStoreFactory = nftPersistenceStoreFactory,
|
||||
nftRuntimeStoreFactory = nftRuntimeStoreFactory,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
networkFactory = mockk(),
|
||||
excludedBlockchains = ExcludedBlockchains(),
|
||||
context = context,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN collections fetched WHEN persistence write fails THEN runtime keeps actual data`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = FakeNFTRuntimeStore(network)
|
||||
val persistenceStore = mockk<NFTPersistenceStore> {
|
||||
coEvery { getCollectionsSync() } returns null
|
||||
coEvery { getSalePricesSync() } returns null
|
||||
coEvery { saveCollections(any()) } throws IOException("Failed to write to disk")
|
||||
}
|
||||
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
|
||||
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
|
||||
coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } returns listOf(createSdkCollection())
|
||||
|
||||
// Act
|
||||
createRepository().refreshCollections(userWalletId, listOf(network))
|
||||
|
||||
// Assert
|
||||
val content = runtimeStore.getCollectionsSync().content
|
||||
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
|
||||
content as NFTCollections.Content.Collections
|
||||
assertThat(content.source).isEqualTo(StatusSource.ACTUAL)
|
||||
assertThat(content.collections).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN runtime has collection missing in persistence WHEN refreshAssets THEN assets saved to runtime`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val sdkCollection = createSdkCollection()
|
||||
val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier)
|
||||
val runtimeStore = FakeNFTRuntimeStore(network)
|
||||
val persistenceStore = mockk<NFTPersistenceStore> {
|
||||
coEvery { getCollectionsSync() } returns null
|
||||
coEvery { getSalePricesSync() } returns null
|
||||
coEvery { saveCollections(any()) } returns Unit
|
||||
}
|
||||
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
|
||||
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
|
||||
coEvery {
|
||||
walletManagersFacade.getNFTCollections(userWalletId, network)
|
||||
} returns listOf(sdkCollection)
|
||||
coEvery {
|
||||
walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier)
|
||||
} returns listOf(createSdkAsset())
|
||||
coEvery { walletManagersFacade.getNFTSalePrice(userWalletId, network, any(), any()) } returns null
|
||||
|
||||
val repository = createRepository()
|
||||
// seed runtime store with the fetched collection, persistence stays empty
|
||||
repository.refreshCollections(userWalletId, listOf(network))
|
||||
|
||||
// Act
|
||||
repository.refreshAssets(userWalletId, network, collectionId)
|
||||
|
||||
// Assert
|
||||
val content = runtimeStore.getCollectionsSync().content
|
||||
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
|
||||
content as NFTCollections.Content.Collections
|
||||
val assets = content.collections.orEmpty().single().assets
|
||||
assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java)
|
||||
assets as NFTCollection.Assets.Value
|
||||
assertThat(assets.items).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fetch returns no assets WHEN refreshAssets THEN empty loaded value saved to runtime`() = runTest {
|
||||
// Arrange
|
||||
val sdkCollection = createSdkCollection()
|
||||
val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier)
|
||||
val runtimeStore = FakeNFTRuntimeStore(network)
|
||||
val persistenceStore = mockk<NFTPersistenceStore> {
|
||||
coEvery { getCollectionsSync() } returns null
|
||||
coEvery { getSalePricesSync() } returns null
|
||||
coEvery { saveCollections(any()) } returns Unit
|
||||
}
|
||||
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
|
||||
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
|
||||
coEvery {
|
||||
walletManagersFacade.getNFTCollections(userWalletId, network)
|
||||
} returns listOf(sdkCollection)
|
||||
coEvery {
|
||||
walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier)
|
||||
} returns emptyList()
|
||||
|
||||
val repository = createRepository()
|
||||
repository.refreshCollections(userWalletId, listOf(network))
|
||||
|
||||
// Act
|
||||
repository.refreshAssets(userWalletId, network, collectionId)
|
||||
|
||||
// Assert
|
||||
val content = runtimeStore.getCollectionsSync().content
|
||||
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
|
||||
content as NFTCollections.Content.Collections
|
||||
val assets = content.collections.orEmpty().single().assets
|
||||
assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java)
|
||||
assets as NFTCollection.Assets.Value
|
||||
assertThat(assets.items).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no cached collections WHEN fetch fails THEN error state saved to runtime`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = FakeNFTRuntimeStore(network)
|
||||
val persistenceStore = mockk<NFTPersistenceStore> {
|
||||
coEvery { getCollectionsSync() } returns null
|
||||
coEvery { getSalePricesSync() } returns null
|
||||
}
|
||||
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
|
||||
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
|
||||
coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500")
|
||||
|
||||
// Act
|
||||
createRepository().refreshCollections(userWalletId, listOf(network))
|
||||
|
||||
// Assert
|
||||
val content = runtimeStore.getCollectionsSync().content
|
||||
assertThat(content).isInstanceOf(NFTCollections.Content.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached collections WHEN fetch fails THEN cache marked as only cache`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = FakeNFTRuntimeStore(network)
|
||||
val persistenceStore = mockk<NFTPersistenceStore> {
|
||||
coEvery { getCollectionsSync() } returns listOf(createSdkCollection())
|
||||
coEvery { getSalePricesSync() } returns null
|
||||
}
|
||||
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
|
||||
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
|
||||
coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500")
|
||||
|
||||
// Act
|
||||
createRepository().refreshCollections(userWalletId, listOf(network))
|
||||
|
||||
// Assert
|
||||
val content = runtimeStore.getCollectionsSync().content
|
||||
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
|
||||
content as NFTCollections.Content.Collections
|
||||
assertThat(content.source).isEqualTo(StatusSource.ONLY_CACHE)
|
||||
assertThat(content.collections).hasSize(1)
|
||||
}
|
||||
|
||||
private fun createSdkCollection(assets: List<SdkNFTAsset> = emptyList()) = SdkNFTCollection(
|
||||
identifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
|
||||
blockchainId = Blockchain.Ethereum.id,
|
||||
name = "Test collection",
|
||||
description = null,
|
||||
logoUrl = null,
|
||||
count = 1,
|
||||
assets = assets,
|
||||
)
|
||||
|
||||
private fun createSdkAsset() = SdkNFTAsset(
|
||||
identifier = SdkNFTAsset.Identifier.EVM(
|
||||
tokenId = BigInteger.ONE,
|
||||
tokenAddress = TOKEN_ADDRESS,
|
||||
contractType = SdkNFTAsset.Identifier.EVM.ContractType.ERC721,
|
||||
),
|
||||
collectionIdentifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
|
||||
blockchainId = Blockchain.Ethereum.id,
|
||||
contractType = "ERC721",
|
||||
owner = null,
|
||||
name = "Test asset",
|
||||
description = null,
|
||||
amount = BigInteger.ONE,
|
||||
decimals = 0,
|
||||
salePrice = null,
|
||||
rarity = null,
|
||||
media = null,
|
||||
traits = emptyList(),
|
||||
)
|
||||
|
||||
private class FakeNFTRuntimeStore(private val network: Network) : NFTRuntimeStore {
|
||||
|
||||
private var collections: NFTCollections = NFTCollections.empty(network)
|
||||
private var prices: Map<NFTAsset.Identifier, NFTSalePrice> = emptyMap()
|
||||
|
||||
override suspend fun initialize(collections: NFTCollections, prices: Map<NFTAsset.Identifier, NFTSalePrice>) {
|
||||
this.collections = collections
|
||||
this.prices = prices
|
||||
}
|
||||
|
||||
override fun getCollections(): Flow<NFTCollections> = flowOf(collections)
|
||||
|
||||
override suspend fun getCollectionsSync(): NFTCollections = collections
|
||||
|
||||
override fun getAsset(
|
||||
collectionId: NFTCollection.Identifier,
|
||||
assetId: NFTAsset.Identifier,
|
||||
): Flow<NFTAsset?> = flowOf(null)
|
||||
|
||||
override fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice> =
|
||||
flowOf(prices[assetId] ?: NFTSalePrice.Empty(assetId))
|
||||
|
||||
override suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice =
|
||||
prices[assetId] ?: NFTSalePrice.Empty(assetId)
|
||||
|
||||
override suspend fun saveCollections(collections: NFTCollections) {
|
||||
this.collections = collections
|
||||
}
|
||||
|
||||
override suspend fun saveSalePrice(salePrice: NFTSalePrice) {
|
||||
prices = prices + (salePrice.assetId to salePrice)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
collections = NFTCollections.empty(network)
|
||||
prices = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001"
|
||||
}
|
||||
}
|
||||
|
|
@ -133,6 +133,23 @@ internal open class BaseActionsFactory(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the unavailability reason for the BUY action
|
||||
*
|
||||
* @param userWallet the user's wallet
|
||||
* @param currency the cryptocurrency to check
|
||||
*/
|
||||
protected fun getBuyUnavailabilityReason(
|
||||
userWallet: UserWallet,
|
||||
currency: CryptoCurrency,
|
||||
): ScenarioUnavailabilityReason {
|
||||
return if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) {
|
||||
ScenarioUnavailabilityReason.BuyUnavailable(currency.name)
|
||||
} else {
|
||||
ScenarioUnavailabilityReason.None
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a "Buy" action to the builder based on the unavailability [reason] */
|
||||
protected fun ActionAvailabilityBuilder.addBuyAction(reason: ScenarioUnavailabilityReason) {
|
||||
val action = ActionState.Buy(unavailabilityReason = reason)
|
||||
|
|
|
|||
|
|
@ -91,7 +91,12 @@ internal class CommonActionsFactory(
|
|||
// endregion
|
||||
|
||||
// region Buy
|
||||
addBuyAction(reason = ScenarioUnavailabilityReason.None)
|
||||
addBuyAction(
|
||||
reason = getBuyUnavailabilityReason(
|
||||
userWallet = userWallet,
|
||||
currency = cryptoCurrencyStatus.currency,
|
||||
),
|
||||
)
|
||||
// endregion
|
||||
|
||||
// region Sell
|
||||
|
|
|
|||
|
|
@ -79,7 +79,12 @@ internal class OutdatedDataActionsFactory(
|
|||
// endregion
|
||||
|
||||
// region Buy
|
||||
addBuyAction(reason = ScenarioUnavailabilityReason.None)
|
||||
addBuyAction(
|
||||
reason = getBuyUnavailabilityReason(
|
||||
userWallet = userWallet,
|
||||
currency = cryptoCurrencyStatus.currency,
|
||||
),
|
||||
)
|
||||
// endregion
|
||||
|
||||
// region Stake
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ internal class UnreachableActionsFactory(
|
|||
// endregion
|
||||
|
||||
// region Buy
|
||||
addBuyAction(reason = ScenarioUnavailabilityReason.None)
|
||||
addBuyAction(
|
||||
reason = getBuyUnavailabilityReason(
|
||||
userWallet = userWallet,
|
||||
currency = cryptoCurrencyStatus.currency,
|
||||
),
|
||||
)
|
||||
// endregion
|
||||
|
||||
// region Receive
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
package com.tangem.domain.tokens.actions
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.domain.card.CardTypesResolver
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.exchange.RampStateManager
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.staking.model.StakingAvailability
|
||||
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
|
||||
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class CommonActionsFactoryTest {
|
||||
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
|
||||
private val rampStateManager: RampStateManager = mockk(relaxed = true)
|
||||
|
||||
private val factory = CommonActionsFactory(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
rampStateManager = rampStateManager,
|
||||
)
|
||||
|
||||
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
|
||||
|
||||
private val cardTypesResolver: CardTypesResolver = mockk()
|
||||
private val userWallet: UserWallet.Cold = mockk(relaxed = true)
|
||||
private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk()
|
||||
|
||||
@BeforeAll
|
||||
fun setupStatic() {
|
||||
// cardTypesResolver is a UserWallet.Cold extension property, so it is stubbed via its file class.
|
||||
mockkStatic("com.tangem.domain.card.common.util.ScanResponseExtKt")
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun tearDownStatic() {
|
||||
unmockkStatic("com.tangem.domain.card.common.util.ScanResponseExtKt")
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(walletManagersFacade, rampStateManager, cardTypesResolver, userWallet, cryptoCurrencyStatus)
|
||||
|
||||
val value = mockk<CryptoCurrencyStatus.Value>(relaxed = true)
|
||||
every { cryptoCurrencyStatus.value } returns value
|
||||
every { cryptoCurrencyStatus.currency } returns currency
|
||||
|
||||
every { userWallet.cardTypesResolver } returns cardTypesResolver
|
||||
every { userWallet.isMultiCurrency } returns false
|
||||
|
||||
coEvery { rampStateManager.getSendUnavailabilityReason(any(), any()) } returns ScenarioUnavailabilityReason.None
|
||||
coEvery { rampStateManager.availableForSell(any(), any(), any()) } returns Unit.right()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Start2Coin cold wallet WHEN create THEN buy action is unavailable`() = runTest {
|
||||
// Arrange
|
||||
every { cardTypesResolver.isStart2Coin() } returns true
|
||||
|
||||
// Act
|
||||
val buyAction = createBuyAction()
|
||||
|
||||
// Assert
|
||||
assertThat(buyAction.unavailabilityReason)
|
||||
.isEqualTo(ScenarioUnavailabilityReason.BuyUnavailable(currency.name))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-Start2Coin cold wallet WHEN create THEN buy action is available`() = runTest {
|
||||
// Arrange
|
||||
every { cardTypesResolver.isStart2Coin() } returns false
|
||||
|
||||
// Act
|
||||
val buyAction = createBuyAction()
|
||||
|
||||
// Assert
|
||||
assertThat(buyAction.unavailabilityReason).isEqualTo(ScenarioUnavailabilityReason.None)
|
||||
}
|
||||
|
||||
private suspend fun createBuyAction(): ActionState.Buy {
|
||||
val actions = factory.create(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencyStatus = cryptoCurrencyStatus,
|
||||
stakingAvailability = StakingAvailability.Unavailable,
|
||||
yieldSupplyAvailability = YieldSupplyAvailability.Unavailable,
|
||||
shouldShowSwapStories = false,
|
||||
)
|
||||
return actions.filterIsInstance<ActionState.Buy>().single()
|
||||
}
|
||||
}
|
||||
|
|
@ -1581,6 +1581,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
feeValue = nativeFee,
|
||||
selectedFeeToken = fee.selectedFeeToken,
|
||||
provider = state.swapProvider,
|
||||
txType = state.txType,
|
||||
)
|
||||
val currencyCheck = manageWarnings(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
|
|
@ -1621,6 +1622,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
* from-currencies but "fee > native balance" for Token from-currencies is resolved here
|
||||
* by consulting `isBalanceEnough` (amount-alone check) directly.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun computeBalanceStatus(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
|
|
@ -1628,9 +1630,10 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
feeValue: BigDecimal,
|
||||
selectedFeeToken: CryptoCurrencyStatus?,
|
||||
provider: SwapProvider,
|
||||
txType: ExpressTxType?,
|
||||
): SwapBalanceStatus {
|
||||
when (provider.type) {
|
||||
ExchangeProviderType.CEX -> {
|
||||
when (resolveQuoteFlow(provider, txType)) {
|
||||
ResolvedFlow.CexLike -> {
|
||||
val includeStatus = getIncludeFeeInAmountInternal(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
|
|
@ -1642,9 +1645,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee)
|
||||
}
|
||||
}
|
||||
ExchangeProviderType.DEX,
|
||||
ExchangeProviderType.DEX_BRIDGE,
|
||||
-> Unit
|
||||
ResolvedFlow.DexLike -> Unit
|
||||
}
|
||||
|
||||
val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
|
|||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
|
|
@ -214,6 +215,76 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section A2: DEX provider re-routed to the CEX-like flow via txType=SEND ([REDACTED_TASK_KEY])
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `DEX provider with SEND txType follows CEX semantics` {
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY]: a DEX-typed provider (e.g. Moonpay trade) whose quote returned txType=SEND
|
||||
* executes as a plain transfer built by the app, so the fee must be folded into the amount
|
||||
* exactly like for a CEX provider.
|
||||
*
|
||||
* GIVEN ExchangeProviderType.DEX, txType = SEND
|
||||
* fromToken is Coin, amount = full native balance (max amount), fee = 0.01
|
||||
* WHEN applySwapFee runs
|
||||
* THEN balanceStatus == FeeAdjustedAmount with adjustedAmount = balance - fee
|
||||
* (NOT InsufficientAmount — the pre-fix behavior that showed "Insufficient funds")
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX with SEND txType — max amount returns FeeAdjustedAmount like CEX`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.0"),
|
||||
txType = ExpressTxType.SEND,
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
val balanceStatus = result.preparedSwapConfigState.balanceStatus
|
||||
assertThat(balanceStatus).isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
|
||||
assertThat((balanceStatus as SwapBalanceStatus.FeeAdjustedAmount).adjustedAmount.value)
|
||||
.isEqualTo(BigDecimal("0.99"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Twin guard: the same max-amount scenario with txType = SWAP keeps the DEX invariant —
|
||||
* the fee is never deducted from the amount, and the amount alone exceeding
|
||||
* balance-with-fee yields InsufficientAmount.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX with SWAP txType — max amount keeps DEX semantics without fee deduction`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.0"),
|
||||
txType = ExpressTxType.SWAP,
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section B: FeePaidCurrency.Token (gasless-token) paths
|
||||
// =========================================================================
|
||||
|
|
@ -749,6 +820,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
fromAmount: SwapAmount,
|
||||
isCoin: Boolean,
|
||||
fromBalance: BigDecimal,
|
||||
txType: ExpressTxType? = null,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val from = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
|
|
@ -778,6 +850,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(providerType),
|
||||
txType = txType,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet
|
|||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
|
|
@ -327,11 +328,16 @@ internal class SwapNotificationsFactory(
|
|||
val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
|
||||
feeCryptoCurrencyStatus.currency != fromCurrency
|
||||
|
||||
val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX
|
||||
// A DEX-typed provider whose quote returned txType=SEND executes as a CEX-style transfer,
|
||||
// so it must follow the same gasless suppression rule as a real CEX provider.
|
||||
val isCexLikeFlow = quoteModel.swapProvider.type == ExchangeProviderType.CEX ||
|
||||
quoteModel.txType == ExpressTxType.SEND
|
||||
|
||||
val isNotEnoughFee = insufficientFee != null && !isCEXProvider
|
||||
val isNotEnoughFee = insufficientFee != null
|
||||
|
||||
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider
|
||||
// Suppress only when the user can actually switch the fee to a token via the gasless
|
||||
// selector; on networks without gasless support the warning must show for CEX too.
|
||||
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCexLikeFlow
|
||||
if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) {
|
||||
add(
|
||||
if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.RateType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.utils.Provider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [SwapNotificationsFactory.getConfirmationStateNotifications], focused on the
|
||||
* `UnableToCoverFeeWarning` gating ([REDACTED_TASK_KEY]):
|
||||
*
|
||||
* | flow | gasless network | expected for InsufficientFee |
|
||||
* |-------------------------------|-----------------|--------------------------------|
|
||||
* | CEX | no | warning shown (the bug fix) |
|
||||
* | CEX | yes | suppressed (fee → token) |
|
||||
* | DEX (txType=null) | yes | warning shown (DEX unchanged) |
|
||||
* | DEX + txType=SEND (CEX-like) | yes | suppressed like a real CEX |
|
||||
* | DEX + txType=SEND (CEX-like) | no | warning shown |
|
||||
*/
|
||||
internal class SwapNotificationsFactoryTest {
|
||||
|
||||
private val actions: UiActions = mockk(relaxed = true)
|
||||
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
|
||||
private val appCurrencyProvider: Provider<AppCurrency> = Provider { AppCurrency.Default }
|
||||
private val appRouter: AppRouter = mockk()
|
||||
|
||||
private val factory = SwapNotificationsFactory(
|
||||
actions = actions,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
appCurrencyProvider = appCurrencyProvider,
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val userWallet: UserWallet = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(isGaslessFeeSupportedForNetwork, appRouter)
|
||||
every { appRouter.stack } returns emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CEX and no gasless support WHEN insufficient fee THEN cover fee warning shown`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns false
|
||||
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val warning = notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>().single()
|
||||
assertThat(warning.currencyName).isEqualTo("Ethereum")
|
||||
assertThat(warning.currencySymbol).isEqualTo("ETH")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CEX and gasless support WHEN insufficient fee THEN cover fee warning suppressed`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns true
|
||||
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX and gasless support WHEN insufficient fee THEN cover fee warning shown`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns true
|
||||
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.DEX)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX with SEND txType and gasless support WHEN insufficient fee THEN warning suppressed like CEX`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns true
|
||||
val quoteModel = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
txType = ExpressTxType.SEND,
|
||||
)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX with SEND txType and no gasless support WHEN insufficient fee THEN warning shown`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns false
|
||||
val quoteModel = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
txType = ExpressTxType.SEND,
|
||||
)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).hasSize(1)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildEthNetwork(): Network = mockk(relaxed = true) {
|
||||
every { rawId } returns "ethereum"
|
||||
every { name } returns "Ethereum"
|
||||
every { currencySymbol } returns "ETH"
|
||||
}
|
||||
|
||||
/** Token from-currency, so the fee is paid in a different (native coin) currency. */
|
||||
private fun buildTokenFromStatus(): SwapCurrencyStatus {
|
||||
val network = buildEthNetwork()
|
||||
val currency = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { this@mockk.network } returns network
|
||||
every { symbol } returns "USDT"
|
||||
every { name } returns "Tether"
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
|
||||
every { amount } returns BigDecimal("100")
|
||||
every { pendingTransactions } returns emptySet()
|
||||
}
|
||||
return SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = CryptoCurrencyStatus(currency = currency, value = statusValue),
|
||||
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildCoinFeeStatus(): CryptoCurrencyStatus {
|
||||
val network = buildEthNetwork()
|
||||
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { this@mockk.network } returns network
|
||||
every { symbol } returns "ETH"
|
||||
every { name } returns "Ethereum"
|
||||
every { decimals } returns 18
|
||||
}
|
||||
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
|
||||
every { amount } returns BigDecimal.ZERO
|
||||
}
|
||||
return CryptoCurrencyStatus(currency = currency, value = statusValue)
|
||||
}
|
||||
|
||||
private fun buildQuotesLoadedState(
|
||||
providerType: ExchangeProviderType,
|
||||
txType: ExpressTxType? = null,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val toStatusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
|
||||
every { amount } returns BigDecimal("1")
|
||||
}
|
||||
val toCurrency = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { network } returns buildEthNetwork()
|
||||
every { symbol } returns "BTC"
|
||||
every { decimals } returns 8
|
||||
}
|
||||
val toSwapCurrencyStatus = SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = CryptoCurrencyStatus(currency = toCurrency, value = toStatusValue),
|
||||
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
|
||||
)
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("50"), 6),
|
||||
swapCurrencyStatus = buildTokenFromStatus(),
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("0.5"), 8),
|
||||
swapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.InsufficientFee(
|
||||
feeCurrencyName = "Ethereum",
|
||||
feeCurrencySymbol = "ETH",
|
||||
),
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
permissionState = PermissionDataState.Empty,
|
||||
swapDataModel = null,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildProvider(providerType),
|
||||
txType = txType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildProvider(type: ExchangeProviderType): SwapProvider = SwapProvider(
|
||||
providerId = "p",
|
||||
rateTypes = listOf(RateType.FLOAT),
|
||||
name = "Provider",
|
||||
type = type,
|
||||
imageLarge = "",
|
||||
termsOfUse = null,
|
||||
privacyPolicy = null,
|
||||
isRecommended = false,
|
||||
slippage = null,
|
||||
isExtraIdSupported = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
|
|||
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
|
||||
import com.tangem.domain.models.account.VirtualAccountOnramp
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||
import com.tangem.domain.pay.model.TangemPayTopUpData
|
||||
|
|
@ -129,7 +128,6 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
|
||||
private val refreshStateJobHolder = JobHolder()
|
||||
private val addToWalletBannerJobHolder = JobHolder()
|
||||
private val frozenStateJobHolder = JobHolder()
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
|
||||
|
||||
|
|
@ -162,9 +160,6 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
isMuted = !state.isFresh,
|
||||
)
|
||||
uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) }
|
||||
state.cards.firstOrNull()?.let { card ->
|
||||
subscribeToCardFrozenState(card.id)
|
||||
}
|
||||
}
|
||||
else -> uiState.update { stateFactory.getLoadingState() }
|
||||
}
|
||||
|
|
@ -195,33 +190,11 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
|
||||
fun isRedesignEnabled(): Boolean = tangemPayFeatureToggles.isRedesignEnabled
|
||||
|
||||
private fun subscribeToCardFrozenState(cardId: String) {
|
||||
frozenStateJobHolder.cancel()
|
||||
cardDetailsRepository
|
||||
.cardFrozenState(cardId)
|
||||
.onEach { frozenState ->
|
||||
// Mirror getLoadedState gating so a live freeze update can't re-enable actions on stale data.
|
||||
val isFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true
|
||||
val isUnfrozen = frozenState == TangemPayCardFrozenState.Unfrozen
|
||||
val areActionButtonsEnabled = isFresh && isUnfrozen
|
||||
val hasWithdrawableBalance = currentStatus.value.balanceOrNull()?.hasWithdrawableAmount == true
|
||||
uiState.update(
|
||||
TangemPayActionButtonsTransformer(
|
||||
stateFactory.getActionButtonsConfig(
|
||||
isAddFundsEnabled = areActionButtonsEnabled,
|
||||
isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
.saveIn(frozenStateJobHolder)
|
||||
}
|
||||
|
||||
override fun onClickAddFunds() {
|
||||
analytics.send(TangemPayAnalyticsEvents.AddFundsClicked())
|
||||
val balance = currentStatus.value.balanceOrNull()
|
||||
if (balance == null) {
|
||||
val address = currentStatus.value.ifLoadedOrNull { it.depositAddress }
|
||||
if (balance == null || address.isNullOrEmpty()) {
|
||||
showBottomSheetError(TangemPayDetailsErrorType.Receive)
|
||||
} else {
|
||||
bottomSheetNavigation.activate(
|
||||
|
|
|
|||
|
|
@ -178,7 +178,11 @@ private fun FeeInfoRow(titleRes: Int, value: String, showDivider: Boolean = fals
|
|||
},
|
||||
valueSlot = {
|
||||
if (value.isEmpty()) {
|
||||
TangemShimmer(style = TangemTheme.typography3.body.medium)
|
||||
TangemShimmer(
|
||||
modifier = Modifier.width(80.dp),
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
} else {
|
||||
TangemRowText(
|
||||
text = value,
|
||||
|
|
@ -299,7 +303,7 @@ private fun ReissueCardSheetPreview(state: TangemPayReissueCardUM) {
|
|||
|
||||
private class TangemPayReissueCardUMPreviewProvider : CollectionPreviewParameterProvider<TangemPayReissueCardUM>(
|
||||
collection = listOf(
|
||||
TangemPayReissueCardUM.stub(error = null),
|
||||
TangemPayReissueCardUM.stub(error = null, feeAmount = ""),
|
||||
TangemPayReissueCardUM.stub(
|
||||
error = TangemPayReissueCardError.InsufficientFunds,
|
||||
cardBalance = "$0.05",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
package com.tangem.features.tangempay.utils
|
||||
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.account.findCardWithId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
internal val AccountStatus.Payment.userWalletId: UserWalletId
|
||||
|
|
@ -22,14 +19,12 @@ internal val AccountStatus.Payment.isDeactivated: Boolean
|
|||
get() = value is PaymentAccountStatusValue.Deactivated
|
||||
|
||||
internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean
|
||||
get() = source == StatusSource.ACTUAL && error == null
|
||||
get() = source.isActual() && error == null
|
||||
|
||||
internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded =
|
||||
value as? PaymentAccountStatusValue.Loaded
|
||||
?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}")
|
||||
|
||||
internal fun AccountStatus.Payment.firstCard(): TangemPayCard = requireLoaded().cards.first()
|
||||
|
||||
internal inline fun <T> AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccountStatusValue.Loaded) -> T): T? {
|
||||
val value = value
|
||||
return if (value is PaymentAccountStatusValue.Loaded) {
|
||||
|
|
@ -46,20 +41,4 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba
|
|||
}
|
||||
|
||||
internal val PaymentAccountStatusValue.Balance.hasWithdrawableAmount: Boolean
|
||||
get() = availableForWithdrawal.signum() > 0
|
||||
|
||||
internal fun AccountStatus.Payment.findCard(
|
||||
initialCardId: String,
|
||||
initialStatus: AccountStatus.Payment,
|
||||
): TangemPayCard? {
|
||||
val value = value
|
||||
|
||||
if (value !is PaymentAccountStatusValue.Loaded || value.source != StatusSource.ACTUAL) return null
|
||||
|
||||
val initialCard = value.findCardWithId(initialCardId)
|
||||
val newCards = initialStatus.ifLoadedOrNull { status ->
|
||||
val initialCardIds = status.cards.mapTo(mutableSetOf()) { it.id }
|
||||
value.cards.filterNot { it.id in initialCardIds }
|
||||
}
|
||||
return initialCard ?: newCards?.firstOrNull()
|
||||
}
|
||||
get() = availableForWithdrawal.signum() > 0
|
||||
|
|
@ -37,27 +37,6 @@ internal class TangemPayDetailsModelTest {
|
|||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
|
||||
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxed = true)
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideFreezeCases")
|
||||
fun `GIVEN frozen state and balance WHEN status loaded THEN action buttons gated accordingly`(
|
||||
case: FreezeCase,
|
||||
) = runTest {
|
||||
// Arrange + Act
|
||||
val model = createModel(
|
||||
testScope = this,
|
||||
statusSource = case.statusSource,
|
||||
frozenState = case.frozenState,
|
||||
availableForWithdrawal = case.availableForWithdrawal,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val state = model.uiState.value
|
||||
assertThat(state.addFundsButton.isEnabled).isEqualTo(case.expectedAddFundsEnabled)
|
||||
assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideMutedCases")
|
||||
fun `GIVEN status source WHEN status loaded THEN balance is muted only when cached`(case: MutedCase) = runTest {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
||||
|
|
@ -83,10 +86,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Refresh the portfolio before searching so a token just added on the backend is present locally.
|
||||
refreshAccountsIfNeeded(userWallet)
|
||||
|
||||
val cryptoCurrency = findCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId)
|
||||
val cryptoCurrency = resolveCryptoCurrency(userWallet, networkId, tokenId)
|
||||
|
||||
if (cryptoCurrency == null) {
|
||||
TangemLogger.e(
|
||||
|
|
@ -130,6 +130,28 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the target currency for the deeplink: refreshes the portfolio when needed and searches for the token.
|
||||
*
|
||||
* A multi-currency link needs both [networkId] and [tokenId] to match a token; a malformed link can never match,
|
||||
* so we skip the refresh/await entirely to avoid wasted backend work and return immediately for the redirect.
|
||||
*/
|
||||
private suspend fun resolveCryptoCurrency(
|
||||
userWallet: UserWallet,
|
||||
networkId: String?,
|
||||
tokenId: String?,
|
||||
): CryptoCurrency? {
|
||||
if (userWallet.isMultiCurrency && (networkId.isNullOrBlank() || tokenId.isNullOrBlank())) return null
|
||||
|
||||
val wasRefreshed = refreshAccountsIfNeeded(userWallet)
|
||||
return findCryptoCurrency(
|
||||
userWallet = userWallet,
|
||||
networkId = networkId,
|
||||
tokenId = tokenId,
|
||||
awaitOnMiss = wasRefreshed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes wallet accounts so a token just added on the backend appears in the local portfolio.
|
||||
*
|
||||
|
|
@ -137,12 +159,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
* on cold start the fresh list is already loaded by the regular auth flow, and single-currency
|
||||
* wallets have a fixed token. The fetch is best-effort — on failure we fall through and try the
|
||||
* current cache, so existing tokens (e.g. swap/onramp pushes) still open without regression.
|
||||
*
|
||||
* @return `true` only when a refresh was actually performed and succeeded. Waiting for the refreshed
|
||||
* list (see [awaitCryptoCurrency]) makes sense only in that case; otherwise there is nothing to wait for.
|
||||
*/
|
||||
private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet) {
|
||||
private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet): Boolean {
|
||||
if (isFromOnNewIntent && userWallet.isMultiCurrency) {
|
||||
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
|
||||
return singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
|
||||
.onLeft { TangemLogger.e("Error on refreshing wallet accounts", it) }
|
||||
.isRight()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
|
||||
|
|
@ -158,26 +185,42 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun findCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) =
|
||||
if (userWallet.isMultiCurrency) {
|
||||
val derivationPath = queryParams[DERIVATION_PATH_KEY]
|
||||
|
||||
getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency ->
|
||||
val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true)
|
||||
val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
|
||||
|
||||
val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card
|
||||
val isCustomDerivation = derivationPath?.equals(currency.network.derivationPath.value) == true
|
||||
val isCorrectDerivation = isDefaultDerivation || isCustomDerivation
|
||||
isNetwork && isCurrency && isCorrectDerivation
|
||||
}
|
||||
} else {
|
||||
singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
|
||||
private suspend fun findCryptoCurrency(
|
||||
userWallet: UserWallet,
|
||||
networkId: String?,
|
||||
tokenId: String?,
|
||||
awaitOnMiss: Boolean,
|
||||
): CryptoCurrency? {
|
||||
if (!userWallet.isMultiCurrency) {
|
||||
return singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
|
||||
?.mainAccount?.cryptoCurrencies?.first()
|
||||
}
|
||||
|
||||
private suspend fun getCryptoCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>? {
|
||||
return singleAccountListSupplier.getSyncOrNull(userWalletId)?.flattenCurrencies()
|
||||
val derivationPath = queryParams[DERIVATION_PATH_KEY]
|
||||
val matches = { currency: CryptoCurrency -> currency.matches(networkId, tokenId, derivationPath) }
|
||||
|
||||
return singleAccountListSupplier.getSyncOrNull(userWallet.walletId)?.flattenCurrencies()?.firstOrNull(matches)
|
||||
// getSyncOrNull returns the stale SharedFlow replay just after a fetch; wait for the refreshed list.
|
||||
// Only when a refresh actually ran and succeeded — otherwise a missing token would block for the full
|
||||
// timeout before the fall-through redirect.
|
||||
?: if (awaitOnMiss) awaitCryptoCurrency(userWallet.walletId, matches) else null
|
||||
}
|
||||
|
||||
private suspend fun awaitCryptoCurrency(
|
||||
userWalletId: UserWalletId,
|
||||
matches: (CryptoCurrency) -> Boolean,
|
||||
): CryptoCurrency? = withTimeoutOrNull(TOKEN_APPEARANCE_TIMEOUT_MILLIS) {
|
||||
singleAccountListSupplier(userWalletId)
|
||||
.mapNotNull { accountList -> accountList.flattenCurrencies().firstOrNull(matches) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
private fun CryptoCurrency.matches(networkId: String?, tokenId: String?, derivationPath: String?): Boolean {
|
||||
val isNetwork = network.rawId.equals(networkId, ignoreCase = true)
|
||||
val isCurrency = id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
|
||||
val isDefaultDerivation = network.derivationPath is Network.DerivationPath.Card
|
||||
val isCustomDerivation = derivationPath?.equals(network.derivationPath.value) == true
|
||||
return isNetwork && isCurrency && (isDefaultDerivation || isCustomDerivation)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
|
|
@ -188,4 +231,8 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
isFromOnNewIntent: Boolean,
|
||||
): DefaultTokenDetailsDeepLinkHandler
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_APPEARANCE_TIMEOUT_MILLIS = 3_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -103,7 +98,6 @@ private fun QuickTopUpBlock_Preview() {
|
|||
),
|
||||
),
|
||||
),
|
||||
modifier = Modifier.padding(TangemTheme.dimens2.x3),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ private fun TokenDetailsBody(
|
|||
item(key = "quick_top_up_block") {
|
||||
QuickTopUpBlock(
|
||||
state = quickTopUpBlock,
|
||||
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0),
|
||||
modifier = itemModifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
|
|
@ -87,7 +83,7 @@ private fun SwapAndSendActionRow(state: TransferUM) {
|
|||
if (state is TransferUM.Content && row == null) return
|
||||
ActionRow(
|
||||
iconRes = CoreR.drawable.ic_exchange_mini_24,
|
||||
title = resourceReference(CoreR.string.common_send_with_swap),
|
||||
title = resourceReference(CoreR.string.send_with_swap_confirm_title),
|
||||
description = resourceReference(CoreR.string.quick_action_send_and_swap_description),
|
||||
row = row,
|
||||
isLoading = state is TransferUM.Loading,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.tangem.utils.logging.TangemLogger
|
|||
import io.mockk.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
|
@ -532,6 +533,9 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
|
|||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null
|
||||
every { singleAccountListSupplier.invoke(userWalletId) } returns MutableStateFlow(
|
||||
AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()),
|
||||
)
|
||||
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
|
@ -565,6 +569,108 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token appears only after refresh WHEN handle deeplink THEN push new route`() = runTest {
|
||||
val userWalletId = UserWalletId("011")
|
||||
val cryptoCurrency = mockCryptoCurrency()
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
|
||||
val staleList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList())
|
||||
val freshList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(cryptoCurrency))
|
||||
val accountListFlow = MutableStateFlow(staleList)
|
||||
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns staleList
|
||||
every { singleAccountListSupplier.invoke(userWalletId) } returns accountListFlow
|
||||
coEvery { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) } answers {
|
||||
accountListFlow.value = freshList
|
||||
Either.Right(Unit)
|
||||
}
|
||||
every {
|
||||
cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency)
|
||||
} just Runs
|
||||
val expectedRoute = AppRoute.CurrencyDetails(userWalletId = userWalletId, currency = cryptoCurrency)
|
||||
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { appRouter.push(route = expectedRoute, onComplete = any()) }
|
||||
verify(exactly = 0) { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cold start AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId("011")
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencies = emptyList(),
|
||||
)
|
||||
|
||||
// Act
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
|
||||
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN refresh failed AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId("011")
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery {
|
||||
singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId))
|
||||
} returns Either.Left(IllegalStateException("service unavailable"))
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencies = emptyList(),
|
||||
)
|
||||
|
||||
// Act
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN malformed deeplink AND refresh succeeded WHEN handle deeplink THEN redirect to main without awaiting`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId("011")
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencies = emptyList(),
|
||||
)
|
||||
val queryParams = mapOf(
|
||||
WALLET_ID_KEY to "011",
|
||||
NETWORK_ID_KEY to "123",
|
||||
DERIVATION_PATH_KEY to "777",
|
||||
// TOKEN_ID_KEY is missing
|
||||
)
|
||||
|
||||
// Act
|
||||
createHandler(scope = this, queryParams, isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
|
||||
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
|
||||
}
|
||||
|
||||
private fun defaultQueryParams() = mapOf(
|
||||
WALLET_ID_KEY to "011",
|
||||
NETWORK_ID_KEY to "123",
|
||||
|
|
|
|||
|
|
@ -129,8 +129,8 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
|
|||
data class BackupError(val onClick: () -> Unit) : WalletNotificationUM(
|
||||
messageUM = TangemMessageUM(
|
||||
id = "BackupErrorNotification",
|
||||
title = resourceReference(id = R.string.warning_backup_errors_title),
|
||||
subtitle = resourceReference(id = R.string.warning_backup_errors_message),
|
||||
title = resourceReference(id = R.string.warning_incomplete_backup_notification_title),
|
||||
subtitle = resourceReference(id = R.string.warning_incomplete_backup_notification_message),
|
||||
messageEffect = TangemMessageEffect.Warning,
|
||||
iconUM = TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_attention_default_24,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import com.tangem.domain.models.currency.yieldSupplyKey
|
|||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.staking.model.StakingAvailability
|
||||
import com.tangem.domain.staking.model.StakingOption
|
||||
import com.tangem.domain.staking.model.optionOrNull
|
||||
import com.tangem.domain.staking.model.common.RewardInfo
|
||||
import com.tangem.domain.staking.model.common.RewardType
|
||||
import com.tangem.domain.staking.model.optionOrNull
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -34,7 +34,7 @@ internal class EarnApyConverter(
|
|||
)
|
||||
}?.value
|
||||
if (yieldSupplyApy != null) {
|
||||
val isActive = value.value.yieldSupplyStatus?.isActive == false
|
||||
val isActive = value.value.yieldSupplyStatus?.isActive == true
|
||||
return EarnApyInfo(
|
||||
text = resourceReference(
|
||||
R.string.yield_module_earn_badge,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue